From 9a006081047818145cad6cea5f1706ffec592f4c Mon Sep 17 00:00:00 2001 From: SFG545 Date: Mon, 16 Feb 2026 21:38:05 -0600 Subject: [PATCH] feat: Implement build state tracking and interactive package specification creation - Added StateTracker to manage build steps and allow resuming interrupted builds. - Updated post_extract function to utilize StateTracker for patch and command execution. - Introduced makefile.rs to handle building and installing packages via Makefile. - Created interactive.rs for an interactive package specification creator with SHA256 computation for source URLs. - Enhanced checksum verification to support multiple algorithms (SHA256, SHA512, MD5). - Added example configuration files in contrib for system-wide and user-level Depot configurations. - Updated tests to cover new functionality and ensure correctness of state tracking and interactive creation. --- .gitlab-ci.yml | 36 +- Cargo.lock | 440 ++++++++++++++++++++++++- Cargo.toml | 7 +- contrib/README.md | 20 ++ contrib/depot.d/build.toml | 17 + contrib/depot.d/package.toml | 6 + contrib/depot.toml | 24 ++ contrib/user.depot.toml.example | 10 + src/builder/autotools.rs | 215 +++++++----- src/builder/bin.rs | 68 ++++ src/builder/cmake.rs | 171 ++++++---- src/builder/custom.rs | 240 ++++++++++---- src/builder/makefile.rs | 193 +++++++++++ src/builder/meson.rs | 147 ++++++--- src/builder/mod.rs | 11 +- src/builder/rust.rs | 8 + src/builder/state.rs | 89 +++++ src/config.rs | 110 ++++++- src/db/mod.rs | 186 ++++++++++- src/db/repo.rs | 138 ++++++++ src/index.rs | 46 ++- src/main.rs | 106 +++++- src/package/interactive.rs | 564 ++++++++++++++++++++++++++++++++ src/package/mod.rs | 2 + src/package/packager.rs | 5 +- src/package/spec.rs | 153 ++++++++- src/source/extractor.rs | 347 +++++++++++++++++--- src/source/fetcher.rs | 393 ++++++++++++++++++++-- src/source/hooks.rs | 25 +- src/source/mod.rs | 158 +++++++-- 30 files changed, 3501 insertions(+), 434 deletions(-) create mode 100644 contrib/README.md create mode 100644 contrib/depot.d/build.toml create mode 100644 contrib/depot.d/package.toml create mode 100644 contrib/depot.toml create mode 100644 contrib/user.depot.toml.example create mode 100644 src/builder/makefile.rs create mode 100644 src/builder/state.rs create mode 100644 src/package/interactive.rs diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 2f65049..ff9b2e6 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,19 +1,29 @@ -# You can override the included template(s) by including variable overrides -# SAST customization: https://docs.gitlab.com/user/application_security/sast/#available-cicd-variables -# Secret Detection customization: https://docs.gitlab.com/user/application_security/secret_detection/pipeline/configure/ -# Dependency Scanning customization: https://docs.gitlab.com/user/application_security/dependency_scanning/#customizing-analyzer-behavior -# Container Scanning customization: https://docs.gitlab.com/user/application_security/container_scanning/#customizing-analyzer-behavior -# Note that environment variables can be set in several places -# See https://docs.gitlab.com/ci/variables/#cicd-variable-precedence stages: -- test -- secret-detection -sast: - stage: test + - test + - secret-detection + include: -- template: Security/SAST.gitlab-ci.yml -- template: Security/Secret-Detection.gitlab-ci.yml + - template: Security/SAST.gitlab-ci.yml + - template: Security/Secret-Detection.gitlab-ci.yml + variables: SECRET_DETECTION_ENABLED: 'true' + +sast: + stage: test + secret_detection: stage: secret-detection + +rust_test: + stage: test + image: rust:1.93.1 + cache: + key: cargo + paths: + - .cargo/ + - target/ + variables: + CARGO_HOME: "$CI_PROJECT_DIR/.cargo" + script: + - cargo test --workspace --all-features --locked diff --git a/Cargo.lock b/Cargo.lock index a241dc7..f29b062 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,6 +19,15 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "aho-corasick" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca972c2ea5f742bfce5687b9aef75506a764f61d37f8f649047846a9686ddb66" +dependencies = [ + "memchr 0.1.11", +] + [[package]] name = "anstream" version = "0.6.21" @@ -93,6 +102,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "aws-lc-rs" version = "1.15.2" @@ -187,6 +202,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9213f7cd7c27e95c2b57c49f0e69b1ea65b27138da84a170133fd21b07659c00" +dependencies = [ + "num", + "time 0.1.45", +] + [[package]] name = "cipher" version = "0.4.4" @@ -199,9 +224,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.58" +version = "4.5.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806" +checksum = "c5caf74d17c3aec5495110c34cc3f78644bfa89af6c8993ed4de2790e49b6499" dependencies = [ "clap_builder", "clap_derive", @@ -209,9 +234,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.58" +version = "4.5.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2" +checksum = "370daa45065b80218950227371916a1633217ae42b2715b2287b606dcd618e24" dependencies = [ "anstream", "anstyle", @@ -259,7 +284,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" dependencies = [ "bytes", - "memchr", + "memchr 2.7.6", ] [[package]] @@ -281,6 +306,15 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -340,6 +374,33 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi 0.3.9", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi 0.3.9", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -365,8 +426,11 @@ dependencies = [ "bzip2", "clap", "flate2", + "ftp", "git2", "indicatif", + "inquire", + "md5", "nix", "reqwest", "rusqlite", @@ -394,6 +458,28 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", +] + [[package]] name = "digest" version = "0.10.7" @@ -416,12 +502,27 @@ dependencies = [ "syn", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dunce" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "encode_unicode" version = "1.0.0" @@ -533,6 +634,17 @@ 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" @@ -577,12 +689,21 @@ dependencies = [ "futures-io", "futures-sink", "futures-task", - "memchr", + "memchr 2.7.6", "pin-project-lite", "pin-utils", "slab", ] +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local 1.1.9", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -602,7 +723,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "wasm-bindgen", ] @@ -956,6 +1077,20 @@ dependencies = [ "generic-array", ] +[[package]] +name = "inquire" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "979f5ab9760427ada4fa5762b2d905e5b12704fb1fada07b6bfa66aeaa586f87" +dependencies = [ + "bitflags", + "crossterm", + "dyn-clone", + "fuzzy-matcher", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -968,7 +1103,7 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" dependencies = [ - "memchr", + "memchr 2.7.6", "serde", ] @@ -1026,6 +1161,22 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kernel32-sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7507624b29483431c0ba2d82aece8ca6cdba9382bff4ddd0f7490560c056098d" +dependencies = [ + "winapi 0.2.8", + "winapi-build", +] + +[[package]] +name = "lazy_static" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf186d1a8aa5f5bee5fd662bc9c1b949e0259e1bcc379d1f006847b0080c7417" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -1066,7 +1217,7 @@ checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ "bitflags", "libc", - "redox_syscall", + "redox_syscall 0.7.0", ] [[package]] @@ -1117,6 +1268,21 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" @@ -1150,6 +1316,21 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "md5" +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" @@ -1179,7 +1360,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", - "wasi", + "log", + "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] @@ -1195,12 +1377,52 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -1237,6 +1459,29 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + [[package]] name = "pbkdf2" version = "0.12.2" @@ -1426,6 +1671,15 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "redox_syscall" version = "0.7.0" @@ -1435,6 +1689,25 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "0.1.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fd4ace6a8cf7860714a2c2280d6c1f7e6a413486c13298bbc86fd3da019402f" +dependencies = [ + "aho-corasick", + "memchr 0.1.11", + "regex-syntax", + "thread_local 0.2.7", + "utf8-ranges", +] + +[[package]] +name = "regex-syntax" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9ec002c35e86791825ed294b50008eea9ddfc8def4420124fbc6b08db834957" + [[package]] name = "reqwest" version = "0.13.2" @@ -1510,6 +1783,15 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.3" @@ -1622,6 +1904,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "security-framework" version = "3.5.1" @@ -1688,7 +1976,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", - "memchr", + "memchr 2.7.6", "serde", "serde_core", "zmij", @@ -1731,6 +2019,37 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.8" @@ -1906,6 +2225,45 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +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" @@ -2115,6 +2473,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + [[package]] name = "unicode-width" version = "0.2.2" @@ -2151,6 +2515,12 @@ 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" @@ -2194,6 +2564,12 @@ 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" @@ -2339,6 +2715,34 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -2348,6 +2752,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" @@ -2860,9 +3270,9 @@ dependencies = [ [[package]] name = "zip" -version = "8.0.0" +version = "8.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79b32dd4ad3aca14ae109f8cce0495ac1c57f6f4f00ad459a40e582f89440d97" +checksum = "6e499faf5c6b97a0d086f4a8733de6d47aee2252b8127962439d8d4311a73f72" dependencies = [ "aes", "bzip2", @@ -2874,11 +3284,11 @@ dependencies = [ "hmac", "indexmap", "lzma-rust2", - "memchr", + "memchr 2.7.6", "pbkdf2", "ppmd-rust", "sha1", - "time", + "time 0.3.47", "typed-path", "zeroize", "zopfli", diff --git a/Cargo.toml b/Cargo.toml index a385d76..dea6187 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ warnings = "deny" anyhow = "1.0.101" ar = "0.9.0" bzip2 = "0.6.1" -clap = { version = "4.5.58", features = ["derive"] } +clap = { version = "4.5.59", features = ["derive"] } flate2 = "1.1.9" git2 = "0.20.4" indicatif = "0.18.4" @@ -28,8 +28,11 @@ toml = "0.9.8" url = "2.5.8" walkdir = "2.5.0" xz2 = "0.1.7" -zip = "8.0.0" +zip = "8.1.0" zstd = { version = "0.13.3", features = ["zstdmt"] } +inquire = "0.9.3" +md5 = "0.8.0" +ftp = "3.0.1" [dev-dependencies] tempfile = "3.25.0" diff --git a/contrib/README.md b/contrib/README.md new file mode 100644 index 0000000..63b237a --- /dev/null +++ b/contrib/README.md @@ -0,0 +1,20 @@ +Contrib: example system and user configuration files for Depot + +Place these files on your system to provide sensible defaults and examples +for `/etc/depot.toml`, `/etc/depot.d/build.toml`, or user-level configs. + +Recommended installation: + + # System-wide + sudo cp contrib/depot.toml /etc/depot.toml + sudo mkdir -p /etc/depot.d + sudo cp -r contrib/depot.d/* /etc/depot.d/ + + # Per-user (example) + mkdir -p ~/.config + cp contrib/user.depot.toml.example ~/.config/depot.toml + +Notes + - These files are examples only. Review and adapt to your distribution and + security policies before deploying. + - The config loader supports append syntax (e.g. `cflags += ["-g"]`). diff --git a/contrib/depot.d/build.toml b/contrib/depot.d/build.toml new file mode 100644 index 0000000..b77f915 --- /dev/null +++ b/contrib/depot.d/build.toml @@ -0,0 +1,17 @@ +[flags] +# Default build flags applied to all packages via /etc/depot.d/build.toml +# Compiler selection +cc = "gcc" + +# Default CFLAGS +cflags = ["-O2"] +cflags += ["-pipe"] + +# Default LDFLAGS +ldflags = ["-Wl,-O1"] + +# CARCH short name (can be overridden per-package) +carch = "x86_64" + +# Example of appending per-package cflags: (will be exposed as build.cflags appends) +# cflags += ["-march=native"] diff --git a/contrib/depot.d/package.toml b/contrib/depot.d/package.toml new file mode 100644 index 0000000..90e9cff --- /dev/null +++ b/contrib/depot.d/package.toml @@ -0,0 +1,6 @@ +[package] +# System-wide package overrides. Example: set default provides or package metadata +# name = "" + +# Example: provide a virtual runtime package name to satisfy runtime deps +# provides = ["filesystem"] diff --git a/contrib/depot.toml b/contrib/depot.toml new file mode 100644 index 0000000..1da988b --- /dev/null +++ b/contrib/depot.toml @@ -0,0 +1,24 @@ +# Example /etc/depot.toml - system-wide configuration for Depot + +[build] +# Installation prefix for package builds +prefix = "/usr" +# Default compiler and archiver +cc = "gcc" +ar = "ar" + +[build.flags] +# Basic compiler flags +cflags = ["-O2"] +# Append additional flags (demonstrates += append support) +cflags += ["-g"] + +# Linker flags +ldflags = ["-Wl,-O1"] + +# Target architecture short name (CARCH). Defaults to host arch if unset. +carch = "x86_64" + +# Target triples (optional) +#chost = "x86_64-pc-linux-gnu" +#cbuild = "x86_64-pc-linux-gnu" diff --git a/contrib/user.depot.toml.example b/contrib/user.depot.toml.example new file mode 100644 index 0000000..ea065f1 --- /dev/null +++ b/contrib/user.depot.toml.example @@ -0,0 +1,10 @@ +# Example per-user configuration (~/.config/depot.toml) + +[build] +prefix = "/usr/local" +cc = "clang" + +[build.flags] +cflags = ["-O2"] +cflags += ["-g"] +carch = "x86_64" diff --git a/src/builder/autotools.rs b/src/builder/autotools.rs index 0248ef3..e885e46 100755 --- a/src/builder/autotools.rs +++ b/src/builder/autotools.rs @@ -57,6 +57,14 @@ pub fn build( 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())); @@ -80,89 +88,123 @@ pub fn build( env_vars.push(("LDFLAGS", ldflags)); } + use crate::builder::state::{BuildStep, StateTracker}; + let mut state = StateTracker::new(&actual_src)?; + // Run configure - println!("Running configure..."); - let mut configure_cmd = Command::new("./configure"); - configure_cmd.current_dir(&actual_src); + let build_dir = if let Some(dir) = &flags.build_dir { + let bdir = actual_src.join(dir); + fs::create_dir_all(&bdir)?; + println!(" Build directory: {}", bdir.display()); + bdir + } else { + actual_src.clone() + }; - crate::builder::prepare_command(&mut configure_cmd, &env_vars); - - 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)); - } - - // 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() { - configure_cmd.arg(format!("--build={}", build)); - } - } - - for arg in &flags.configure { - configure_cmd.arg(arg); - } - - let status = configure_cmd - .status() - .with_context(|| format!("Failed to run configure in {}", actual_src.display()))?; - - if !status.success() { - anyhow::bail!("configure failed with status: {}", status); - } - - // Run make - println!("Running make..."); - let mut make_cmd = Command::new("make"); - make_cmd.current_dir(&actual_src); - make_cmd.arg("-j").arg(num_cpus().to_string()); - - crate::builder::prepare_command(&mut make_cmd, &env_vars); - - let status = make_cmd - .status() - .with_context(|| format!("Failed to run make in {}", actual_src.display()))?; - - if !status.success() { - anyhow::bail!("make failed with status: {}", status); - } - - // Run post-compile hooks (after make, before make install) - hooks::run_post_compile_commands(spec, &actual_src, destdir)?; - - // Run make install with fakeroot if not root - println!( - "Running make install{}...", - if fakeroot::is_root() { - "" + if !state.is_done(BuildStep::Configured) { + println!("Running configure..."); + let configure_path = if flags.build_dir.is_some() { + "../configure" } else { - " (with internal fakeroot for build)" + "./configure" + }; + println!(" Configure path: {}", configure_path); + + let mut configure_cmd = Command::new(configure_path); + configure_cmd.current_dir(&build_dir); + + crate::builder::prepare_command(&mut configure_cmd, &env_vars); + + 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)); } - ); - let mut install_cmd = fakeroot::wrap_install_command("make", destdir); - install_cmd.current_dir(&actual_src); - install_cmd.arg("install"); + // 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() { + configure_cmd.arg(format!("--build={}", build)); + } + } - let mut install_env = env_vars.clone(); - install_env.push(("DESTDIR", destdir.to_string_lossy().into_owned())); - crate::builder::prepare_command(&mut install_cmd, &install_env); + for arg in &flags.configure { + configure_cmd.arg(arg); + } - let status = install_cmd - .status() - .with_context(|| format!("Failed to run make install for {}", spec.package.name))?; + let status = configure_cmd + .status() + .with_context(|| format!("Failed to run configure in {}", build_dir.display()))?; - if !status.success() { - anyhow::bail!("make install failed with status: {}", status); + if !status.success() { + anyhow::bail!("configure failed with status: {}", status); + } + state.mark_done(BuildStep::Configured)?; + } else { + println!("Skipping configure (already done)"); } - // Run post-install hooks (after make install) - hooks::run_post_install_commands(spec, &actual_src, destdir)?; + if !state.is_done(BuildStep::PostCompileDone) { + // Run make + println!("Running make..."); + let mut make_cmd = Command::new("make"); + make_cmd.current_dir(&build_dir); + make_cmd.arg("-j").arg(num_cpus().to_string()); + + crate::builder::prepare_command(&mut make_cmd, &env_vars); + + let status = make_cmd + .status() + .with_context(|| format!("Failed to run make in {}", build_dir.display()))?; + + if !status.success() { + anyhow::bail!("make failed with status: {}", status); + } + + // Run post-compile hooks (after make, before make install) + hooks::run_post_compile_commands(spec, &actual_src, destdir)?; + state.mark_done(BuildStep::PostCompileDone)?; + } else { + println!("Skipping make and post-compile hooks (already done)"); + } + + if !state.is_done(BuildStep::PostInstallDone) { + // Run make install with fakeroot if not root + println!( + "Running make install{}...", + if fakeroot::is_root() { + "" + } else { + " (with internal fakeroot for build)" + } + ); + + let mut install_cmd = fakeroot::wrap_install_command("make", destdir); + install_cmd.current_dir(&build_dir); + install_cmd.arg("install"); + + let mut install_env = env_vars.clone(); + install_env.push(("DESTDIR", destdir.to_string_lossy().into_owned())); + crate::builder::prepare_command(&mut install_cmd, &install_env); + + let status = install_cmd + .status() + .with_context(|| format!("Failed to run make install for {}", spec.package.name))?; + + if !status.success() { + anyhow::bail!("make install failed with status: {}", status); + } + + // Run post-install hooks (after make install) + hooks::run_post_install_commands(spec, &actual_src, destdir)?; + state.mark_done(BuildStep::PostInstallDone)?; + } else { + println!("Skipping make install and post-install hooks (already done)"); + } Ok(()) } @@ -207,3 +249,30 @@ fn expand_shell_commands(input: &str, cc: &str) -> Result { Ok(result) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_expand_shell_commands_simple() -> Result<()> { + let out = expand_shell_commands("x $(echo foo) y", "gcc")?; + assert_eq!(out, "x foo y"); + Ok(()) + } + + #[test] + fn test_expand_shell_commands_replace_cc() -> Result<()> { + // 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("") ); + Ok(()) + } + + #[test] + fn test_num_cpus_at_least_one() { + let n = num_cpus(); + assert!(n >= 1); + } +} diff --git a/src/builder/bin.rs b/src/builder/bin.rs index 20ac809..b57b83e 100644 --- a/src/builder/bin.rs +++ b/src/builder/bin.rs @@ -40,3 +40,71 @@ pub fn build( Ok(()) } + +#[cfg(test)] +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; + + fn mk_spec(name: &str, version: &str) -> PackageSpec { + PackageSpec { + package: PackageInfo { + name: name.into(), + version: version.into(), + revision: 1, + description: "d".into(), + homepage: "h".into(), + license: "MIT".into(), + }, + packages: Vec::new(), + alternatives: Default::default(), + manual_sources: Vec::new(), + source: vec![crate::package::Source { + url: "h".into(), + sha256: "s".into(), + extract_dir: "e".into(), + patches: Vec::new(), + post_extract: Vec::new(), + }], + build: Build { + build_type: BuildType::Bin, + flags: BuildFlags::default(), + }, + dependencies: Dependencies::default(), + spec_dir: std::path::PathBuf::from("."), + } + } + + #[test] + fn test_bin_build_copies_files_and_symlinks() -> Result<()> { + let tmp_src = tempdir()?; + let tmp_dest = tempdir()?; + let src = tmp_src.path(); + let dest = tmp_dest.path(); + + // Create a directory and files + fs::create_dir_all(src.join("usr/bin"))?; + fs::write(src.join("usr/bin/hello"), b"hi")?; + + // Create a symlink + let target = src.join("usr/lib/libdummy.so"); + fs::create_dir_all(target.parent().unwrap())?; + fs::write(&target, b"lib")?; + unix_fs::symlink(&target, src.join("usr/lib/libdummy.so.link"))?; + + let spec = mk_spec("bin-test", "1.0"); + build(&spec, src, dest, None)?; + + // Check copied file + assert!(dest.join("usr/bin/hello").exists()); + + // Check symlink target exists at dest + let link_path = dest.join("usr/lib/libdummy.so.link"); + assert!(link_path.exists()); + + Ok(()) + } +} diff --git a/src/builder/cmake.rs b/src/builder/cmake.rs index 6f94689..c55e531 100755 --- a/src/builder/cmake.rs +++ b/src/builder/cmake.rs @@ -70,6 +70,14 @@ pub fn build( } env_vars.push(("CC", cc)); + // 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())); @@ -91,71 +99,95 @@ pub fn build( None }; + use crate::builder::state::{BuildStep, StateTracker}; + let mut state = StateTracker::new(&actual_src)?; + // Run cmake configure - println!("Running cmake configure..."); - let mut cmake_cmd = Command::new("cmake"); - cmake_cmd.current_dir(&build_dir); - cmake_cmd.arg("-S").arg(&actual_src); - cmake_cmd.arg("-B").arg(&build_dir); - cmake_cmd.arg(format!("-DCMAKE_INSTALL_PREFIX={}", prefix)); - cmake_cmd.arg("-DCMAKE_BUILD_TYPE=Release"); + if !state.is_done(BuildStep::Configured) { + println!("Running cmake configure..."); + let mut cmake_cmd = Command::new("cmake"); + cmake_cmd.current_dir(&build_dir); + cmake_cmd.arg("-S").arg(&actual_src); + cmake_cmd.arg("-B").arg(&build_dir); + cmake_cmd.arg(format!("-DCMAKE_INSTALL_PREFIX={}", prefix)); + cmake_cmd.arg("-DCMAKE_BUILD_TYPE=Release"); - // Add toolchain file for cross-compilation - if let Some(ref tf) = toolchain_file { - cmake_cmd.arg(format!("-DCMAKE_TOOLCHAIN_FILE={}", tf.display())); - } - - // Add custom configure flags from spec (supports cross-compilation overrides) - for flag in &flags.configure { - // Expand environment variables in the flag - let expanded = expand_env_vars(flag); - cmake_cmd.arg(&expanded); - } - - crate::builder::prepare_command(&mut cmake_cmd, &env_vars); - - let status = cmake_cmd.status().context("Failed to run cmake")?; - if !status.success() { - anyhow::bail!("cmake configure failed"); - } - - // Run cmake build - println!("Running cmake build..."); - let mut build_cmd = Command::new("cmake"); - build_cmd.arg("--build").arg(&build_dir); - build_cmd.arg("-j").arg(num_cpus().to_string()); - - crate::builder::prepare_command(&mut build_cmd, &env_vars); - - let status = build_cmd - .status() - .with_context(|| format!("Failed to run cmake build for {}", spec.package.name))?; - if !status.success() { - anyhow::bail!("cmake build failed"); - } - - // Run cmake install with fakeroot if not root - println!( - "Running cmake install{}...", - if fakeroot::is_root() { - "" - } else { - " (with fakeroot)" + // Add toolchain file for cross-compilation + if let Some(ref tf) = toolchain_file { + cmake_cmd.arg(format!("-DCMAKE_TOOLCHAIN_FILE={}", tf.display())); } - ); - let mut install_cmd = fakeroot::wrap_install_command("cmake", destdir); - install_cmd.arg("--install").arg(&build_dir); + // Add custom configure flags from spec (supports cross-compilation overrides) + for flag in &flags.configure { + // Expand environment variables in the flag + let expanded = expand_env_vars(flag); + cmake_cmd.arg(&expanded); + } - let mut install_env = env_vars.clone(); - install_env.push(("DESTDIR", destdir.to_string_lossy().into_owned())); - crate::builder::prepare_command(&mut install_cmd, &install_env); + crate::builder::prepare_command(&mut cmake_cmd, &env_vars); - let status = install_cmd - .status() - .with_context(|| format!("Failed to run cmake install for {}", spec.package.name))?; - if !status.success() { - anyhow::bail!("cmake install failed"); + let status = cmake_cmd.status().context("Failed to run cmake")?; + if !status.success() { + anyhow::bail!("cmake configure failed"); + } + state.mark_done(BuildStep::Configured)?; + } else { + println!("Skipping cmake configure (already done)"); + } + + if !state.is_done(BuildStep::PostCompileDone) { + // Run cmake build + println!("Running cmake build..."); + let mut build_cmd = Command::new("cmake"); + build_cmd.arg("--build").arg(&build_dir); + build_cmd.arg("-j").arg(num_cpus().to_string()); + + crate::builder::prepare_command(&mut build_cmd, &env_vars); + + let status = build_cmd + .status() + .with_context(|| format!("Failed to run cmake build for {}", spec.package.name))?; + if !status.success() { + anyhow::bail!("cmake build failed"); + } + + // Note: CMake doesn't have a direct "after make, before install" hook as easy as autotools, + // but we can run it here. + crate::source::hooks::run_post_compile_commands(spec, &actual_src, destdir)?; + state.mark_done(BuildStep::PostCompileDone)?; + } else { + println!("Skipping cmake build and post-compile hooks (already done)"); + } + + if !state.is_done(BuildStep::PostInstallDone) { + // Run cmake install with fakeroot if not root + println!( + "Running cmake install{}...", + if fakeroot::is_root() { + "" + } else { + " (with fakeroot)" + } + ); + + let mut install_cmd = fakeroot::wrap_install_command("cmake", destdir); + install_cmd.arg("--install").arg(&build_dir); + + let mut install_env = env_vars.clone(); + install_env.push(("DESTDIR", destdir.to_string_lossy().into_owned())); + crate::builder::prepare_command(&mut install_cmd, &install_env); + + let status = install_cmd + .status() + .with_context(|| format!("Failed to run cmake install for {}", spec.package.name))?; + if !status.success() { + anyhow::bail!("cmake install failed"); + } + + crate::source::hooks::run_post_install_commands(spec, &actual_src, destdir)?; + state.mark_done(BuildStep::PostInstallDone)?; + } else { + println!("Skipping cmake install and post-install hooks (already done)"); } Ok(()) @@ -177,3 +209,24 @@ fn num_cpus() -> usize { .map(|n| n.get()) .unwrap_or(1) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_expand_env_vars_replaces_vars() { + // Set a test env var + unsafe { std::env::set_var("DEPOT_TEST_FOO", "bar") }; + let input = "$DEPOT_TEST_FOO and ${DEPOT_TEST_FOO}"; + let out = expand_env_vars(input); + assert!(out.contains("bar")); + assert_eq!(out, "bar and bar"); + } + + #[test] + fn test_num_cpus_at_least_one() { + let n = num_cpus(); + assert!(n >= 1); + } +} diff --git a/src/builder/custom.rs b/src/builder/custom.rs index af6aa82..14497fc 100755 --- a/src/builder/custom.rs +++ b/src/builder/custom.rs @@ -23,6 +23,25 @@ pub fn build( // For custom builds, look for a build.sh script in the source directory let build_script = src_dir.join("build.sh"); + // If the extracted source doesn't include build.sh but the spec directory does, + // copy it into the source dir (this makes `depot install ` behave + // like the spec's build.sh being part of the package when appropriate). + 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()))?; + // Ensure executable bit + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&build_script)?.permissions(); + perms.set_mode(0o755); + fs::set_permissions(&build_script, perms)?; + } + println!("Using build.sh from spec dir: {}", spec_build.display()); + } + if !build_script.exists() { anyhow::bail!( "Custom build type requires build.sh in source directory: {}", @@ -30,74 +49,177 @@ pub fn build( ); } - println!( - "Running custom build script{}...", - if fakeroot::is_root() { - "" - } else { - " (with fakeroot)" - } - ); + use crate::builder::state::{BuildStep, StateTracker}; + let mut state = StateTracker::new(&src_dir)?; - let mut cmd = fakeroot::wrap_install_command("bash", destdir); - cmd.current_dir(src_dir); - cmd.arg(&build_script); + if !state.is_done(BuildStep::PostInstallDone) { + println!( + "Running custom build script{}...", + if fakeroot::is_root() { + "" + } else { + " (with fakeroot)" + } + ); - 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(" ") + let build_dir = if let Some(dir) = &flags.build_dir { + let bdir = src_dir.join(dir); + fs::create_dir_all(&bdir)?; + bdir } else { - format!( - "{} -Wl,--dynamic-linker={}", - flags.ldflags.join(" "), - flags.libc - ) + src_dir.to_path_buf() }; - 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(( - "NYAPM_SPECDIR", - spec.spec_dir.to_string_lossy().into_owned(), - )); + let mut cmd = fakeroot::wrap_install_command("bash", destdir); + cmd.current_dir(&build_dir); - if !flags.chost.is_empty() { - env_vars.push(("CHOST", flags.chost.clone())); - } - if !flags.cbuild.is_empty() { - env_vars.push(("CBUILD", flags.cbuild.clone())); - } + // Ensure build script path is absolute for when we are in a sub-build-dir + let abs_build_script = if build_script.is_absolute() { + build_script.clone() + } else { + std::env::current_dir()?.join(&build_script) + }; + cmd.arg(&abs_build_script); - // 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))); + 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::prepare_command(&mut cmd, &env_vars); + + let status = cmd + .status() + .with_context(|| format!("Failed to run build script: {}", build_script.display()))?; + + if !status.success() { + anyhow::bail!("Custom build script failed with status: {}", status); + } + state.mark_done(BuildStep::PostInstallDone)?; } else { - env_vars.push(("CC", flags.cc.clone())); - env_vars.push(("AR", flags.ar.clone())); - } - - crate::builder::prepare_command(&mut cmd, &env_vars); - - let status = cmd - .status() - .with_context(|| format!("Failed to run build script: {}", build_script.display()))?; - - if !status.success() { - anyhow::bail!("Custom build script failed with status: {}", status); + println!("Skipping custom build script (already done)"); } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::package::{Build, BuildFlags, BuildType, Dependencies, PackageInfo}; + use tempfile::tempdir; + + fn mk_spec(name: &str, version: &str) -> PackageSpec { + PackageSpec { + package: PackageInfo { + name: name.into(), + version: version.into(), + revision: 1, + description: "d".into(), + homepage: "h".into(), + license: "MIT".into(), + }, + packages: Vec::new(), + alternatives: Default::default(), + manual_sources: Vec::new(), + source: vec![crate::package::Source { + url: "h".into(), + sha256: "s".into(), + extract_dir: "e".into(), + patches: Vec::new(), + post_extract: Vec::new(), + }], + build: Build { + build_type: BuildType::Custom, + flags: BuildFlags::default(), + }, + dependencies: Dependencies::default(), + spec_dir: std::path::PathBuf::from("."), + } + } + + #[test] + fn test_build_errors_without_build_sh() -> Result<()> { + let tmp_src = tempdir()?; + let tmp_dest = tempdir()?; + + let spec = mk_spec("custom-no-build", "1.0"); + + let res = build(&spec, tmp_src.path(), tmp_dest.path(), None); + assert!(res.is_err()); + Ok(()) + } + + #[test] + fn test_build_uses_build_sh_from_spec_dir() -> Result<()> { + let tmp_src = tempdir()?; + let tmp_dest = tempdir()?; + let spec_dir = tempdir()?; + + // write a no-op build.sh into spec_dir + let build_sh = spec_dir.path().join("build.sh"); + std::fs::write(&build_sh, "#!/bin/sh\nexit 0\n")?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&build_sh)?.permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&build_sh, perms)?; + } + + let mut spec = mk_spec("custom-from-spec", "1.0"); + 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)?; + // If we reached here, build() succeeded and build.sh was copied into src + assert!(tmp_src.path().join("build.sh").exists()); + Ok(()) + } +} diff --git a/src/builder/makefile.rs b/src/builder/makefile.rs new file mode 100644 index 0000000..e8827c3 --- /dev/null +++ b/src/builder/makefile.rs @@ -0,0 +1,193 @@ +use crate::builder::state::{BuildStep, StateTracker}; +use crate::cross::CrossConfig; +use crate::package::PackageSpec; +use anyhow::{Context, Result}; +use std::path::Path; +use std::process::Command; + +pub fn build( + spec: &PackageSpec, + src_dir: &Path, + destdir: &Path, + cross: Option<&CrossConfig>, +) -> 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: 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 + if !flags.rootfs.is_empty() && flags.rootfs != "/" { + env_vars.push(("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..."); + + for cmd_str in &spec.build.flags.makefile_commands { + let cmd_str = spec.expand_vars(cmd_str); + println!(" Executing: {}", cmd_str); + + let mut cmd = Command::new("sh"); + cmd.arg("-c").arg(&cmd_str); + cmd.current_dir(src_dir); + crate::builder::prepare_command(&mut cmd, &env_vars); + + let status = cmd + .status() + .with_context(|| format!("Failed to run build command: {}", cmd_str))?; + if !status.success() { + anyhow::bail!("Build command failed: {}", cmd_str); + } + } + + crate::source::hooks::run_post_compile_commands(spec, src_dir, destdir)?; + state.mark_done(BuildStep::PostCompileDone)?; + } else { + println!("Skipping makefile build commands (already done)"); + } + + if !state.is_done(BuildStep::PostInstallDone) { + // Run install commands with fakeroot + println!( + "Running makefile install commands{}...", + if crate::fakeroot::is_root() { + "" + } else { + " (with fakeroot)" + } + ); + + for cmd_str in &spec.build.flags.makefile_install_commands { + let cmd_str = spec.expand_vars(cmd_str); + println!(" Executing: {}", cmd_str); + + // We need to run each command under fakeroot + let mut cmd = crate::fakeroot::wrap_install_command("sh", destdir); + cmd.arg("-c").arg(&cmd_str); + cmd.current_dir(src_dir); + + let mut install_env = env_vars.clone(); + install_env.push(("DESTDIR", destdir.to_string_lossy().into_owned())); + crate::builder::prepare_command(&mut cmd, &install_env); + + let status = cmd + .status() + .with_context(|| format!("Failed to run install command: {}", cmd_str))?; + if !status.success() { + anyhow::bail!("Install command failed: {}", cmd_str); + } + } + + crate::source::hooks::run_post_install_commands(spec, src_dir, destdir)?; + state.mark_done(BuildStep::PostInstallDone)?; + } else { + println!("Skipping makefile install commands (already done)"); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::package::{Build, BuildFlags, BuildType, Dependencies, PackageInfo}; + use std::fs; + use tempfile::tempdir; + + fn mk_spec(name: &str, version: &str) -> PackageSpec { + PackageSpec { + package: PackageInfo { + name: name.into(), + version: version.into(), + revision: 1, + description: "d".into(), + homepage: "h".into(), + license: "MIT".into(), + }, + packages: Vec::new(), + alternatives: Default::default(), + manual_sources: Vec::new(), + source: vec![crate::package::Source { + url: "h".into(), + sha256: "s".into(), + extract_dir: "e".into(), + patches: Vec::new(), + post_extract: Vec::new(), + }], + build: Build { + build_type: BuildType::Makefile, + flags: BuildFlags::default(), + }, + dependencies: Dependencies::default(), + spec_dir: std::path::PathBuf::from("."), + } + } + + #[test] + fn test_makefile_build_runs_commands() -> Result<()> { + let tmp_src = tempdir()?; + let tmp_dest = tempdir()?; + let src_path = tmp_src.path(); + let dest_path = tmp_dest.path(); + + let mut spec = mk_spec("test-make", "1.0"); + spec.build.flags.makefile_commands = vec![ + "echo 'building' > built.txt".into(), + "echo 'more build' >> built.txt".into(), + ]; + spec.build.flags.makefile_install_commands = vec![ + "mkdir -p $DESTDIR/usr/bin".into(), + "cp built.txt $DESTDIR/usr/bin/installed.txt".into(), + ]; + + build(&spec, src_path, dest_path, None)?; + + // Verify build step + let built_file = src_path.join("built.txt"); + assert!(built_file.exists()); + let content = fs::read_to_string(&built_file)?; + assert!(content.contains("building")); + assert!(content.contains("more build")); + + // Verify install step + let installed_file = dest_path.join("usr/bin/installed.txt"); + assert!(installed_file.exists()); + let installed_content = fs::read_to_string(&installed_file)?; + assert_eq!(content, installed_content); + + // Verify state persistence + let state_tracker = StateTracker::new(src_path)?; + assert!(state_tracker.is_done(BuildStep::PostCompileDone)); + assert!(state_tracker.is_done(BuildStep::PostInstallDone)); + + Ok(()) + } +} diff --git a/src/builder/meson.rs b/src/builder/meson.rs index acc0946..a060b46 100755 --- a/src/builder/meson.rs +++ b/src/builder/meson.rs @@ -54,12 +54,20 @@ pub fn build( } 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())); + } + // Extract prefix from configure flags let prefix = flags .configure @@ -75,65 +83,87 @@ pub fn build( None }; + use crate::builder::state::{BuildStep, StateTracker}; + let mut state = StateTracker::new(src_dir)?; + // Run meson setup - println!("Running meson setup..."); - let mut meson_cmd = Command::new("meson"); - meson_cmd.current_dir(src_dir); - meson_cmd.arg("setup"); - meson_cmd.arg(&build_dir); - meson_cmd.arg(format!("--prefix={}", prefix)); - meson_cmd.arg("--buildtype=release"); + if !state.is_done(BuildStep::Configured) { + println!("Running meson setup..."); + let mut meson_cmd = Command::new("meson"); + meson_cmd.current_dir(src_dir); + meson_cmd.arg("setup"); + meson_cmd.arg(&build_dir); + meson_cmd.arg(format!("--prefix={}", prefix)); + meson_cmd.arg("--buildtype=release"); - // Add cross file for cross-compilation - if let Some(ref cf) = cross_file { - meson_cmd.arg(format!("--cross-file={}", cf.display())); - } - - crate::builder::prepare_command(&mut meson_cmd, &env_vars); - - let status = meson_cmd.status().context("Failed to run meson setup")?; - if !status.success() { - anyhow::bail!("meson setup failed"); - } - - // Run ninja build - println!("Running ninja..."); - let mut ninja_cmd = Command::new("ninja"); - ninja_cmd.current_dir(&build_dir); - ninja_cmd.arg("-j").arg(num_cpus().to_string()); - - crate::builder::prepare_command(&mut ninja_cmd, &env_vars); - - let status = ninja_cmd - .status() - .with_context(|| format!("Failed to run ninja for {}", spec.package.name))?; - if !status.success() { - anyhow::bail!("ninja build failed"); - } - - // Run meson install with fakeroot if not root - println!( - "Running meson install{}...", - if fakeroot::is_root() { - "" - } else { - " (with fakeroot)" + // Add cross file for cross-compilation + if let Some(ref cf) = cross_file { + meson_cmd.arg(format!("--cross-file={}", cf.display())); } - ); - let mut install_cmd = fakeroot::wrap_install_command("meson", destdir); - install_cmd.arg("install"); - install_cmd.arg("-C").arg(&build_dir); + crate::builder::prepare_command(&mut meson_cmd, &env_vars); - let mut install_env = env_vars.clone(); - install_env.push(("DESTDIR", destdir.to_string_lossy().into_owned())); - crate::builder::prepare_command(&mut install_cmd, &install_env); + let status = meson_cmd.status().context("Failed to run meson setup")?; + if !status.success() { + anyhow::bail!("meson setup failed"); + } + state.mark_done(BuildStep::Configured)?; + } else { + println!("Skipping meson setup (already done)"); + } - let status = install_cmd - .status() - .with_context(|| format!("Failed to run meson install for {}", spec.package.name))?; - if !status.success() { - anyhow::bail!("meson install failed"); + if !state.is_done(BuildStep::PostCompileDone) { + // Run ninja build + println!("Running ninja..."); + let mut ninja_cmd = Command::new("ninja"); + ninja_cmd.current_dir(&build_dir); + ninja_cmd.arg("-j").arg(num_cpus().to_string()); + + crate::builder::prepare_command(&mut ninja_cmd, &env_vars); + + let status = ninja_cmd + .status() + .with_context(|| format!("Failed to run ninja for {}", spec.package.name))?; + if !status.success() { + anyhow::bail!("ninja build failed"); + } + + crate::source::hooks::run_post_compile_commands(spec, src_dir, destdir)?; + state.mark_done(BuildStep::PostCompileDone)?; + } else { + println!("Skipping ninja build and post-compile hooks (already done)"); + } + + if !state.is_done(BuildStep::PostInstallDone) { + // Run meson install with fakeroot if not root + println!( + "Running meson install{}...", + if fakeroot::is_root() { + "" + } else { + " (with fakeroot)" + } + ); + + let mut install_cmd = fakeroot::wrap_install_command("meson", destdir); + install_cmd.arg("install"); + install_cmd.arg("-C").arg(&build_dir); + + let mut install_env = env_vars.clone(); + install_env.push(("DESTDIR", destdir.to_string_lossy().into_owned())); + crate::builder::prepare_command(&mut install_cmd, &install_env); + + let status = install_cmd + .status() + .with_context(|| format!("Failed to run meson install for {}", spec.package.name))?; + if !status.success() { + anyhow::bail!("meson install failed"); + } + + crate::source::hooks::run_post_install_commands(spec, src_dir, destdir)?; + state.mark_done(BuildStep::PostInstallDone)?; + } else { + println!("Skipping meson install and post-install hooks (already done)"); } Ok(()) @@ -144,3 +174,14 @@ fn num_cpus() -> usize { .map(|n| n.get()) .unwrap_or(1) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_num_cpus_at_least_one() { + let n = num_cpus(); + assert!(n >= 1); + } +} diff --git a/src/builder/mod.rs b/src/builder/mod.rs index 5de8e7d..a1b1845 100755 --- a/src/builder/mod.rs +++ b/src/builder/mod.rs @@ -4,8 +4,10 @@ mod autotools; mod bin; mod cmake; mod custom; +mod makefile; mod meson; mod rust; +pub mod state; use crate::cross::CrossConfig; use crate::package::{BuildType, PackageSpec}; @@ -17,7 +19,7 @@ use std::process::Command; pub fn prepare_command(cmd: &mut Command, env_vars: &[(&str, String)]) { cmd.env_clear(); // Preserve essential environment variables - for var in &["PATH", "LANG", "HOME", "SHELL", "DESTDIR"] { + for var in &["PATH", "LANG", "HOME", "SHELL", "DESTDIR", "DEPOT_ROOTFS"] { if let Ok(val) = std::env::var(var) { cmd.env(var, val); } @@ -56,6 +58,7 @@ pub fn build( 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), } } #[cfg(test)] @@ -71,6 +74,7 @@ mod tests { unsafe { std::env::set_var("PATH", "/usr/bin"); std::env::set_var("HOME", "/home/test"); + std::env::set_var("DEPOT_ROOTFS", "/my/rootfs"); } prepare_command(&mut cmd, &[("MYVAR", "myval".to_string())]); @@ -83,6 +87,11 @@ mod tests { envs.get(std::ffi::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")), + Some(&Some(std::ffi::OsString::from("/my/rootfs").as_os_str())) + ); } #[test] diff --git a/src/builder/rust.rs b/src/builder/rust.rs index d8f6846..80d6adc 100755 --- a/src/builder/rust.rs +++ b/src/builder/rust.rs @@ -52,6 +52,11 @@ pub fn build( env_vars.push(("RUSTFLAGS", flags.rustflags.join(" "))); } + // CARCH support + if !flags.carch.is_empty() { + env_vars.push(("CARCH", flags.carch.clone())); + } + // If cross-compiling, set linker via CARGO_TARGET_*_LINKER if let Some(cc_cfg) = cross { // Convert target triple to uppercase with underscores for env var @@ -71,6 +76,9 @@ pub fn build( env_vars.push(("RUSTUP_TOOLCHAIN", "stable".to_string())); } + // Export DEPOT_ROOTFS so build scripts can detect the target rootfs + env_vars.push(("DEPOT_ROOTFS", flags.rootfs.clone())); + // Run cargo build println!( "Running cargo build ({})...", diff --git a/src/builder/state.rs b/src/builder/state.rs new file mode 100644 index 0000000..342eabb --- /dev/null +++ b/src/builder/state.rs @@ -0,0 +1,89 @@ +//! Build state tracking to allow resuming interrupted builds. + +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum BuildStep { + PatchesApplied, + PostExtractDone, + Configured, + PostCompileDone, + PostInstallDone, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct State { + completed_steps: HashSet, +} + +pub struct StateTracker { + state_file: PathBuf, + state: State, +} + +impl StateTracker { + pub fn new(source_dir: &Path) -> Result { + let state_file = source_dir.join(".depot_state"); + let state = if state_file.exists() { + let content = fs::read_to_string(&state_file)?; + toml::from_str(&content).unwrap_or_default() + } else { + State::default() + }; + + Ok(Self { state_file, state }) + } + + pub fn is_done(&self, step: BuildStep) -> bool { + self.state.completed_steps.contains(&step) + } + + pub fn mark_done(&mut self, step: BuildStep) -> Result<()> { + self.state.completed_steps.insert(step); + self.save() + } + + fn save(&self) -> Result<()> { + let content = toml::to_string_pretty(&self.state)?; + fs::write(&self.state_file, content)?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn test_state_tracker_init() -> Result<()> { + let dir = tempdir()?; + let tracker = StateTracker::new(dir.path())?; + assert!(!tracker.state_file.exists()); + Ok(()) + } + + #[test] + fn test_mark_done_and_persistence() -> Result<()> { + let dir = tempdir()?; + let mut tracker = StateTracker::new(dir.path())?; + + assert!(!tracker.is_done(BuildStep::Configured)); + tracker.mark_done(BuildStep::Configured)?; + assert!(tracker.is_done(BuildStep::Configured)); + + // Check file exists + assert!(tracker.state_file.exists()); + + // Reload + let tracker2 = StateTracker::new(dir.path())?; + assert!(tracker2.is_done(BuildStep::Configured)); + assert!(!tracker2.is_done(BuildStep::PostCompileDone)); + + Ok(()) + } +} diff --git a/src/config.rs b/src/config.rs index 0b573f4..32a79a4 100755 --- a/src/config.rs +++ b/src/config.rs @@ -6,6 +6,7 @@ use std::fs; use std::path::{Path, PathBuf}; /// Global configuration settings +#[derive(Clone)] pub struct Config { /// Directory for cached source tarballs pub cache_dir: PathBuf, @@ -19,6 +20,11 @@ pub struct Config { pub package_overrides: toml::Value, /// Appends found in system TOML files (key -> values to append) pub appends: HashMap>, + /// Mirrors mapping read from /etc/depot.d/mirrors.toml (reponame -> git url) + pub mirrors: std::collections::HashMap, + /// Directory where git mirrors are cloned (absolute path). Defaults to + /// /usr/src/depot unless overridden in depot.toml + pub repo_clone_dir: PathBuf, } impl Config { @@ -63,6 +69,8 @@ impl Config { build_overrides: toml::Value::Table(toml::map::Map::new()), package_overrides: toml::Value::Table(toml::map::Map::new()), appends: HashMap::new(), + mirrors: std::collections::HashMap::new(), + repo_clone_dir: abs_rootfs.join("usr/src/depot"), }; if let Err(e) = config.load_system(&abs_rootfs) { @@ -74,9 +82,18 @@ impl Config { /// Load system-level and user-level overrides pub fn load_system(&mut self, rootfs: &Path) -> Result<()> { - let mut config_paths = vec![rootfs.join("etc/depot.toml")]; + // Load host system config (fallback) and then the requested rootfs config + // so that rootfs-specific settings override host settings when present. + let mut config_paths: Vec = Vec::new(); + let host_etc = PathBuf::from("/etc/depot.toml"); + let rootfs_etc = rootfs.join("etc/depot.toml"); - // Add user-level config paths if we are not root or if HOME is set + if host_etc.exists() && host_etc != rootfs_etc { + config_paths.push(host_etc); + } + config_paths.push(rootfs_etc); + + // Add user-level config paths (user override) — these should remain highest precedence if let Ok(home) = std::env::var("HOME") { let home_path = PathBuf::from(home); config_paths.push(home_path.join(".config/depot.toml")); @@ -104,14 +121,14 @@ impl Config { } } - // Keep existing etc/depot.d/ support for backward compatibility/modular config + // Keep existing etc/depot.d/ support. Check host (/etc/depot.d) first as a + // fallback, then the rootfs path so the rootfs can override host settings. + let host_build = PathBuf::from("/etc/depot.d/build.toml"); let build_path = rootfs.join("etc/depot.d/build.toml"); - if build_path.exists() { - let content = fs::read_to_string(&build_path).with_context(|| { - format!( - "Failed to read system build config: {}", - build_path.display() - ) + + 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()) })?; let (val, appends) = self.preprocess_toml(&content)?; merge_toml_values(&mut self.build_overrides, &val); @@ -120,13 +137,23 @@ 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()) + })?; + let (val, appends) = self.preprocess_toml(&content)?; + merge_toml_values(&mut self.build_overrides, &val); + for (k, v) in appends { + self.appends.insert(format!("build.{}", k), v); + } + } + + let host_package = PathBuf::from("/etc/depot.d/package.toml"); let package_path = rootfs.join("etc/depot.d/package.toml"); - if package_path.exists() { - let content = fs::read_to_string(&package_path).with_context(|| { - format!( - "Failed to read system package config: {}", - package_path.display() - ) + + 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()) })?; let (val, appends) = self.preprocess_toml(&content)?; merge_toml_values(&mut self.package_overrides, &val); @@ -135,6 +162,43 @@ 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()) + })?; + let (val, appends) = self.preprocess_toml(&content)?; + merge_toml_values(&mut self.package_overrides, &val); + for (k, v) in appends { + self.appends.insert(format!("package.{}", k), v); + } + } + + // Load mirrors file: /etc/depot.d/mirrors.toml + let mirrors_path = rootfs.join("etc/depot.d/mirrors.toml"); + if mirrors_path.exists() { + let content = fs::read_to_string(&mirrors_path).with_context(|| { + format!("Failed to read mirrors config: {}", mirrors_path.display()) + })?; + let val: toml::Value = toml::from_str(&content)?; + if let Some(table) = val.as_table() { + for (k, v) in table { + if let Some(s) = v.as_str() { + self.mirrors.insert(k.clone(), s.to_string()); + } + } + } + } + + // Allow overriding repo clone dir via [repo] clone_dir in depot.toml + if let Some(repo_table) = self.build_overrides.get("repo").and_then(|v| v.as_table()) { + if let Some(clone_val) = repo_table.get("clone_dir").and_then(|v| v.as_str()) { + let p = PathBuf::from(clone_val); + // If relative path, make it relative to rootfs + let repo_dir = if p.is_absolute() { p } else { rootfs.join(p) }; + self.repo_clone_dir = repo_dir; + } + } + Ok(()) } } @@ -304,9 +368,12 @@ cflags += ["-g"] #[test] fn test_config_non_root_fallback() { - // We can't easily mock is_root() here without more complex infrastructure, - // but we can at least check if the logic for abs_rootfs == "/" triggers - // the HOME join if we were non-root. + // Make the test deterministic by overriding HOME for the duration of the check. + // 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()); } + let config = Config::for_rootfs(Path::new("/")); if !crate::fakeroot::is_root() { @@ -322,6 +389,13 @@ cflags += ["-g"] // If running as root (e.g. in some CI), it should use /var assert_eq!(config.cache_dir, PathBuf::from("/var/cache/depot/sources")); } + + // restore original HOME + if let Some(v) = orig_home { + unsafe { std::env::set_var("HOME", v); } + } else { + unsafe { std::env::remove_var("HOME"); } + } } #[test] diff --git a/src/db/mod.rs b/src/db/mod.rs index ee9a783..f8826ff 100755 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -69,9 +69,66 @@ pub fn register_package(db_path: &Path, spec: &PackageSpec, destdir: &Path) -> R )?; } - // Insert files + // Detect ownership conflicts and separate into auto-removable vs fatal. + let mut fatal_conflicts: Vec<(String, String)> = Vec::new(); + let mut auto_conflicts: Vec<(String, String)> = Vec::new(); + + for file in &manifest.files { + let owner_res: rusqlite::Result = tx.query_row( + "SELECT p.name FROM files f JOIN packages p ON f.package_id = p.id WHERE f.path = ?1", + params![file], + |row| row.get(0), + ); + if let Ok(owner) = owner_res { + if owner != spec.package.name { + if is_auto_removable_path(file) { + auto_conflicts.push((file.clone(), owner)); + } else { + fatal_conflicts.push((file.clone(), owner)); + } + } + } + } + + if !fatal_conflicts.is_empty() { + let mut msg = String::from("File ownership conflict detected:\n"); + for (f, owner) in &fatal_conflicts { + msg.push_str(&format!(" {} -> owned by {}\n", f, owner)); + } + anyhow::bail!("{}", msg); + } + + // For auto-removable conflicts, remove previous DB ownership entries and + // attempt to delete the on-disk file if we can infer the rootfs. + if !auto_conflicts.is_empty() { + let rootfs_opt = detect_rootfs_from_db_path(db_path); + for (f, owner) in &auto_conflicts { + // Remove DB row(s) marking the previous owner for this path + tx.execute( + "DELETE FROM files WHERE path = ?1 AND package_id = (SELECT id FROM packages WHERE name = ?2)", + params![f, owner], + )?; + + if let Some(rootfs) = &rootfs_opt { + let disk_path = rootfs.join(f); + if disk_path.exists() { + let _ = std::fs::remove_file(&disk_path); + println!( + "Auto-removed conflicting path: {} (was owned by {})", + f, owner + ); + } + } else { + println!( + "Auto-cleared DB ownership for path: {} (previously owned by {})", + f, owner + ); + } + } + } + + // Insert files (no fatal conflicts remain) for file in &manifest.files { - // Enforce single-owner semantics for file paths. tx.execute( "INSERT INTO files (package_id, path) VALUES (?1, ?2)", params![pkg_id, file], @@ -103,13 +160,20 @@ pub fn get_package_files(db_path: &Path, name: &str) -> Result> { } let conn = Connection::open(db_path)?; - let pkg_id: i64 = conn - .query_row( - "SELECT id FROM packages WHERE name = ?1", - params![name], - |row| row.get(0), - ) - .context(format!("Package '{}' not found", name))?; + + // If the package is not present in the DB (fresh install), treat that as + // "no previously installed files" rather than an error. + let pkg_id_res: rusqlite::Result = conn.query_row( + "SELECT id FROM packages WHERE name = ?1", + params![name], + |row| row.get(0), + ); + + let pkg_id = match pkg_id_res { + Ok(id) => id, + Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(Vec::new()), + Err(e) => return Err(e.into()), + }; let mut stmt = conn.prepare("SELECT path FROM files WHERE package_id = ?1")?; let files: Vec = stmt @@ -340,6 +404,40 @@ fn init_db(conn: &Connection) -> Result<()> { Ok(()) } +/// 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. +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") + || p.starts_with("usr/lib/perl5") + || p.starts_with("usr/share/perl5") + { + return true; + } + + false +} + +/// Attempt to infer the rootfs path from the database path. If the DB path +/// follows the standard layout `/var/lib/depot/packages.db`, return +/// the `` portion. Otherwise return None. +fn detect_rootfs_from_db_path(db_path: &Path) -> Option { + // Look for the suffix `var/lib/depot/packages.db` and return the ancestor 4 levels up + if db_path.ends_with(std::path::Path::new("var/lib/depot/packages.db")) { + return db_path.ancestors().nth(4).map(|p| p.to_path_buf()); + } + None +} + /// Calculate which files need to be removed during an upgrade. /// Returns paths that exist in the old version but NOT in the new version. pub fn calculate_upgrade_paths( @@ -379,6 +477,7 @@ mod tests { homepage: "h".into(), license: "MIT".into(), }, + packages: Vec::new(), alternatives: Alternatives { provides: vec![format!("{}-virtual", name)], replaces: Vec::new(), @@ -448,6 +547,75 @@ mod tests { assert_eq!(version.as_deref(), Some("2.0")); } + #[test] + fn register_package_detects_conflicting_files() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("packages.db"); + + // Install package 'alpha' owning usr/bin/shared + 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/bin")).unwrap(); + std::fs::write(dest_a.join("usr/bin/shared"), "a").unwrap(); + register_package(&db_path, &spec_a, &dest_a).unwrap(); + + // Try to install package 'beta' that also includes the same path -> should fail + 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/bin")).unwrap(); + std::fs::write(dest_b.join("usr/bin/shared"), "b").unwrap(); + + let res = register_package(&db_path, &spec_b, &dest_b); + assert!(res.is_err()); + let err = format!("{}", res.err().unwrap()); + assert!(err.contains("File ownership conflict detected")); + assert!(err.contains("usr/bin/shared")); + assert!(err.contains("alpha")); + } + + #[test] + fn register_package_auto_clears_safe_conflicts() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("packages.db"); + + // Install package 'alpha' owning usr/share/info/dir + 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(); + register_package(&db_path, &spec_a, &dest_a).unwrap(); + + // Now install package 'beta' that also provides usr/share/info/dir -> 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(); + + // This should succeed and transfer ownership of the 'dir' 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())); + let files_b = get_package_files(&db_path, "beta").unwrap(); + assert!(files_b.contains(&"usr/share/info/dir".to_string())); + } + + #[test] + fn get_package_files_missing_package_returns_empty() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("packages.db"); + + // Create an empty database file with schema but no packages + let conn = Connection::open(&db_path).unwrap(); + init_db(&conn).unwrap(); + drop(conn); + + // Querying files for a package that doesn't exist should return an empty list + let files = get_package_files(&db_path, "nonexistent").unwrap(); + assert!(files.is_empty()); + } + #[test] fn remove_package_tolerates_missing_files_and_cleans_db() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src/db/repo.rs b/src/db/repo.rs index 0a56eed..ee5f5b6 100644 --- a/src/db/repo.rs +++ b/src/db/repo.rs @@ -208,6 +208,144 @@ impl RepoManager { } } +/// Synchronize git mirrors into /usr/src/depot/ +pub fn sync_mirrors(repo_dir: &std::path::Path, mirrors: &std::collections::HashMap) -> Result<()> { + use git2::{Repository, FetchOptions, Cred, RemoteCallbacks, ResetType, build::RepoBuilder}; + use std::os::unix::fs::PermissionsExt; + + let base = repo_dir.to_path_buf(); + if !base.exists() { + std::fs::create_dir_all(&base)?; + } + + for (name, url) in mirrors { + let target = base.join(name); + if !target.exists() { + println!("Cloning mirror '{}' -> {}", name, target.display()); + + // Use git2 RepoBuilder to clone + let mut cb = RemoteCallbacks::new(); + cb.credentials(|_url, username_from_url, _allowed| { + // Try default credentials (ssh-agent / keychain) + Cred::ssh_key_from_agent(username_from_url.unwrap_or("git")) + }); + + let mut fo = FetchOptions::new(); + fo.remote_callbacks(cb); + + let mut builder = RepoBuilder::new(); + builder.fetch_options(fo); + builder.clone(url, &target).with_context(|| format!("Failed to clone {}", url))?; + } else { + println!("Updating mirror '{}' in {}", name, target.display()); + // Open repository and fetch updates + let repo = Repository::open(&target) + .with_context(|| format!("Failed to open repository at {}", target.display()))?; + + let mut cb = RemoteCallbacks::new(); + cb.credentials(|_url, username_from_url, _allowed| { + Cred::ssh_key_from_agent(username_from_url.unwrap_or("git")) + }); + + let mut fo = FetchOptions::new(); + 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) + .with_context(|| format!("Failed to fetch updates for {}", url))?; + + // Try to fast-forward HEAD to origin/HEAD by resetting to FETCH_HEAD if present + if let Ok(fetch_head) = repo.find_reference("FETCH_HEAD") { + if let Some(oid) = fetch_head.target() { + let obj = repo.find_object(oid, None)?; + repo.reset(&obj, ResetType::Hard, None)?; + } + } + } + + // Make the tree readable and writable by everyone + for entry in walkdir::WalkDir::new(&target) { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o777))?; + } else if path.is_file() { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o666))?; + } + } + } + + Ok(()) +} + +/// Show status for each mirror repository: path, exists, branch/HEAD, latest commit, dirty +pub fn mirrors_status(repo_dir: &std::path::Path, mirrors: &std::collections::HashMap) -> Result<()> { + use git2::Repository; + + let base = repo_dir.to_path_buf(); + if !base.exists() { + println!("Repo base directory does not exist: {}", base.display()); + return Ok(()); + } + + for (name, _url) in mirrors { + let target = base.join(name); + println!("--- {} ---", name); + if !target.exists() { + println!("Not cloned: {}", target.display()); + continue; + } + + match Repository::open(&target) { + Ok(repo) => { + // Branch / HEAD + let head = repo.head().ok(); + let branch = head + .as_ref() + .and_then(|h| h.shorthand().map(|s| s.to_string())) + .unwrap_or_else(|| "(no branch)".to_string()); + + // Latest commit OID + let oid = repo.refname_to_id("HEAD").ok(); + 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(); + if let Some(oid) = oid { + if let Ok(commit) = repo.find_commit(oid) { + let t = commit.time().seconds(); + commit_time = format!("{}", t); + } + } + + // Dirty status + let statuses = match repo.statuses(None) { + Ok(s) => s, + Err(_) => { + println!("Warning: failed to read status for {}", target.display()); + continue; + } + }; + 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); + println!("HEAD OID: {}", short); + if !commit_time.is_empty() { + println!("Latest commit time (epoch): {}", commit_time); + } + println!("Dirty: {}", if dirty { "yes" } else { "no" }); + } + Err(e) => { + println!("Failed to open repo at {}: {}", target.display(), e); + } + } + } + + Ok(()) +} + fn num_cpus() -> usize { std::thread::available_parallelism() .map(|n| n.get()) diff --git a/src/index.rs b/src/index.rs index d7d59fb..81b1968 100755 --- a/src/index.rs +++ b/src/index.rs @@ -17,16 +17,18 @@ pub struct PackageIndex { } impl PackageIndex { - /// Build index by scanning packages/*/*.toml - pub fn build() -> Self { + /// Build index by scanning packages/*/*.toml and configured repo dir. + /// + /// Use `build_with_repo_dir` to provide an explicit repo dir. + + /// Build index scanning the local `packages/` directory and an optional + /// system repo dir (e.g., /usr/src/depot). If `repo_dir` is None, the + /// default `/usr/src/depot` is used. + pub fn build_with_repo_dir(repo_dir: Option) -> Self { let mut index = Self::default(); let packages_dir = PathBuf::from("packages"); - if !packages_dir.exists() { - return index; - } - - // Scan all package directories + // Scan local packages//*.toml if let Ok(entries) = fs::read_dir(&packages_dir) { for entry in entries.flatten() { if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { @@ -34,25 +36,16 @@ impl PackageIndex { } let dir = entry.path(); - // Find all .toml files in this directory if let Ok(files) = fs::read_dir(&dir) { for file in files.flatten() { let path = file.path(); if path.extension().map(|e| e == "toml").unwrap_or(false) { - // Try to parse the spec if let Ok(spec) = PackageSpec::from_file(&path) { - // Index by name index .by_name .insert(spec.package.name.clone(), path.clone()); - - // Index by provides 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()); } } } @@ -61,6 +54,25 @@ impl PackageIndex { } } + // Also scan system mirrors under provided repo_dir (or default) + let sys_dir = repo_dir.unwrap_or_else(|| PathBuf::from("/usr/src/depot")); + + if sys_dir.exists() { + for entry in walkdir::WalkDir::new(&sys_dir).min_depth(1).max_depth(5) { + if let Ok(entry) = entry { + 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()); + for provided in &spec.alternatives.provides { + index.by_provides.entry(provided.clone()).or_default().push(path.clone()); + } + } + } + } + } + } + println!( "Indexed {} packages ({} provides)", index.by_name.len(), diff --git a/src/main.rs b/src/main.rs index 6f67a6c..d1e9158 100755 --- a/src/main.rs +++ b/src/main.rs @@ -79,6 +79,12 @@ enum Commands { }, /// Show current configuration Config, + /// Create a new package specification interactively + MakeSpec { + /// Output file path (defaults to .toml) + #[arg(short, long)] + output: Option, + }, } #[derive(Subcommand)] @@ -89,6 +95,10 @@ enum RepoCommands { #[arg(default_value = ".")] dir: PathBuf, }, + /// Sync git mirrors configured in /etc/depot.d/mirrors.toml into /usr/src/depot + Sync, + /// Show status of configured git mirrors + Status, } fn main() -> Result<()> { @@ -99,11 +109,27 @@ fn main() -> Result<()> { spec_or_archive, spec, } => { - let spec_path = spec.unwrap_or(spec_or_archive); - println!("Installing package from: {}", spec_path.display()); + let mut spec_path = spec.unwrap_or(spec_or_archive); + // Load configuration early so we can use the configured repo clone dir let config = config::Config::for_rootfs(&cli.rootfs); + // Repo clone dir is available via `config.repo_clone_dir` and + // is passed explicitly to index builders below. + + // If the provided path doesn't exist, treat it as a package name and + // try to locate a spec under configured repo dir or local packages/. + if !spec_path.exists() { + let name = spec_path.to_string_lossy().to_string(); + println!("Looking up package '{}' in local indexes...", name); + let pkg_index = index::PackageIndex::build_with_repo_dir(Some(config.repo_clone_dir.clone())); + if let Some(found) = pkg_index.find(&name) { + spec_path = found; + } + } + + println!("Installing package from: {}", spec_path.display()); + let (pkg_spec, staging_dir): (package::PackageSpec, Option) = if spec_path.to_string_lossy().ends_with(".tar.zst") { // Install from archive @@ -177,6 +203,7 @@ fn main() -> Result<()> { .unwrap_or("") .to_string(), }, + packages: Vec::new(), alternatives: package::Alternatives::default(), manual_sources: Vec::new(), source: Vec::new(), @@ -228,9 +255,6 @@ fn main() -> Result<()> { pkg_spec.package.name, pkg_spec.package.version, pkg_spec.package.revision ); - // Get config relative to rootfs - let config = config::Config::for_rootfs(&cli.rootfs); - // Ensure database directory exists std::fs::create_dir_all(&config.db_dir).with_context(|| { format!( @@ -277,7 +301,7 @@ fn main() -> Result<()> { if input == "y" || input == "yes" || input.is_empty() { // Build package index for fast lookups - let pkg_index = index::PackageIndex::build(); + 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() { @@ -374,9 +398,13 @@ fn main() -> Result<()> { let tx = staging::install_atomic(&destdir, &cli.rootfs, &tx_base, &remove_paths)?; // 6. Register in database (rollback install on DB error) - if let Err(e) = db::register_package(&db_path, &pkg_spec, &destdir) { - let _ = tx.rollback(); - return Err(e); + 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()?; @@ -455,15 +483,24 @@ fn main() -> Result<()> { staging::process(&destdir, &pkg_spec)?; - // Create package archive - let packager = package::Packager::new(pkg_spec, destdir.clone(), config); + // Create package archive(s) — support multiple outputs from a single spec. let arch = cli .cross_prefix .as_deref() .unwrap_or(std::env::consts::ARCH); - let pkg_file = packager.create_package(Path::new("."), arch)?; - println!("Build complete. Package created: {}", pkg_file.display()); + let mut created_files = Vec::new(); + 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 pkg_file = packager.create_package(Path::new("."), arch)?; + created_files.push(pkg_file); + } + + for f in &created_files { + println!("Build complete. Package created: {}", f.display()); + } } Commands::Info { package } => { // Try as file first, then as installed package name @@ -493,6 +530,27 @@ fn main() -> Result<()> { let db_path = repo.create_repo_db()?; println!("Created repository database: {}", db_path.display()); } + RepoCommands::Sync => { + // Only root may run sync + if !crate::fakeroot::is_root() { + anyhow::bail!("The 'repo sync' command must be run as root"); + } + let config = config::Config::for_rootfs(&cli.rootfs); + if config.mirrors.is_empty() { + 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()); + } + } + RepoCommands::Status => { + let config = config::Config::for_rootfs(&cli.rootfs); + if config.mirrors.is_empty() { + println!("No mirrors configured in /etc/depot.d/mirrors.toml"); + } else { + db::repo::mirrors_status(&config.repo_clone_dir, &config.mirrors)?; + } + } }, Commands::Config => { let config = config::Config::for_rootfs(&cli.rootfs); @@ -508,6 +566,28 @@ fn main() -> Result<()> { } } } + Commands::MakeSpec { output } => { + let spec = package::create_interactive()?; + // Produce a minimal TOML for interactive-created specs (omit defaults) + let toml_string = package::spec_to_minimal_toml(&spec)?; + + let output_path = output.unwrap_or_else(|| PathBuf::from(format!("{}.toml", spec.package.name))); + + if output_path.exists() { + println!( + "Warning: File {} already exists. Overwrite? [y/N]", + output_path.display() + ); + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + if !input.trim().eq_ignore_ascii_case("y") { + anyhow::bail!("Aborted"); + } + } + + fs::write(&output_path, toml_string)?; + println!("Package specification saved to {}", output_path.display()); + } } Ok(()) diff --git a/src/package/interactive.rs b/src/package/interactive.rs new file mode 100644 index 0000000..a6a1afb --- /dev/null +++ b/src/package/interactive.rs @@ -0,0 +1,564 @@ +use crate::package::{ + Alternatives, Build, BuildFlags, BuildType, Dependencies, PackageInfo, PackageSpec, Source, +}; +use anyhow::{Context, Result}; +use inquire::{Confirm, Select, Text}; +use sha2::{Digest, Sha256}; +use std::fmt; +use std::io::Read; +use std::path::PathBuf; +use std::time::Duration; +use url::Url; + +impl fmt::Display for BuildType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + BuildType::Autotools => write!(f, "Autotools"), + BuildType::CMake => write!(f, "CMake"), + BuildType::Meson => write!(f, "Meson"), + BuildType::Custom => write!(f, "Custom"), + BuildType::Rust => write!(f, "Rust"), + BuildType::Makefile => write!(f, "Makefile"), + BuildType::Bin => write!(f, "Binary installer"), + } + } +} + +/// Try to compute the SHA256 hex for the given URL or local path. +/// +/// Supports: `http`, `https`, `ftp`, `file`, and plain local filesystem paths. +/// Returns Ok(hex) on success or Err on any failure (caller should treat failure as "no default"). +fn compute_sha256_for_url(u: &str) -> anyhow::Result { + // helper to hash a Read + fn hash_reader(r: &mut R) -> anyhow::Result { + let mut hasher = Sha256::new(); + let mut buf = [0u8; 8192]; + loop { + let n = r.read(&mut buf)?; + if n == 0 { break; } + hasher.update(&buf[..n]); + } + Ok(format!("{:x}", hasher.finalize())) + } + + // Try to parse as URL first; if parsing fails, treat as local path. + if let Ok(parsed) = Url::parse(u) { + match parsed.scheme() { + "http" | "https" => { + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(20)) + .build() + .with_context(|| "failed to build http client")?; + let mut resp = client.get(u).send().with_context(|| format!("failed to GET {}", u))?; + if !resp.status().is_success() { + anyhow::bail!("HTTP status {}", resp.status()); + } + return hash_reader(&mut resp); + } + "ftp" => { + let host = parsed.host_str().context("ftp url missing host")?; + let port = parsed.port_or_known_default().unwrap_or(21); + let addr = format!("{}:{}", host, port); + let mut ftp_stream = ftp::FtpStream::connect(addr.as_str()) + .with_context(|| format!("failed to connect to {}", addr))?; + let user = if parsed.username().is_empty() { "anonymous" } else { parsed.username() }; + let pass = parsed.password().unwrap_or("anonymous@"); + ftp_stream.login(user, pass).with_context(|| "ftp login failed")?; + let mut result_hex = None; + let path = parsed.path(); + let candidates = [path.to_string(), path.trim_start_matches('/').to_string()]; + for p in candidates.iter().filter(|s| !s.is_empty()) { + if let Ok(res) = ftp_stream.retr(p, |reader| { + // reuse hash_reader by adapting reader to trait object + let mut r = reader; + hash_reader(&mut r).map_err(|e| ftp::FtpError::ConnectionError(std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))) + }) { + result_hex = Some(res); + break; + } + } + ftp_stream.quit().ok(); + if let Some(h) = result_hex { return Ok(h); } + anyhow::bail!("ftp retrieval failed") + } + "file" => { + if let Ok(fp) = parsed.to_file_path() { + let mut f = std::fs::File::open(fp)?; + return hash_reader(&mut f); + } + anyhow::bail!("invalid file URL") + } + _ => anyhow::bail!("unsupported URL scheme") + } + } + + // Treat as local path if it exists + let p = std::path::Path::new(u); + if p.exists() { + let mut f = std::fs::File::open(p)?; + return hash_reader(&mut f); + } + + anyhow::bail!("could not compute sha256 for '{}': unsupported or unreachable", u) +} + +pub fn create_interactive() -> Result { + println!("Interactive Package Specification Creator"); + println!("-----------------------------------------"); + + // Ask early whether this is a GNU project so we can pre-fill homepage and + // avoid repeating the same question later for autotools sources. + let is_gnu_project = Confirm::new("Is this a GNU project?") + .with_help_message("Automatically fill GNU-specific defaults (homepage, mirrors)") + .with_default(false) + .prompt()?; + + // Default package name to the current directory basename when possible. + let default_name = std::env::current_dir() + .ok() + .and_then(|p| p.file_name().map(|s| s.to_string_lossy().into_owned())) + .unwrap_or_else(|| "zlib".into()); + + let name = Text::new("Package Name:") + .with_help_message("e.g. zlib, bash, linux") + .with_default(default_name.as_str()) + .prompt()?; + + if name.is_empty() { + anyhow::bail!("Package name cannot be empty"); + } + + let version = Text::new("Version:") + .with_help_message("e.g. 1.2.11, 5.1") + .with_placeholder("1.0.0") + .prompt()?; + + let description = Text::new("Description:") + .with_help_message("A short description of the package") + .prompt()?; + + let homepage_default = if is_gnu_project { + format!("https://www.gnu.org/software/{}/", name) + } else { + String::new() + }; + + let homepage = Text::new("Homepage:") + .with_help_message("Project website URL") + .with_default(homepage_default.as_str()) + .prompt()?; + + let license = Text::new("License:") + .with_help_message("e.g. MIT, GPL-3.0, Apache-2.0") + .with_placeholder("MIT") + .prompt()?; + + // Present core build system choices only (keep interactive concise) + let build_types = vec![ + BuildType::Autotools, + BuildType::CMake, + BuildType::Meson, + BuildType::Makefile, + BuildType::Rust, + ]; + + let build_type = Select::new("Build System:", build_types) + .with_help_message("Select the build system used by the package (common choices)") + .prompt()?; + + // If the build system is Autotools, offer a convenience prompt for GNU-hosted tarballs. + let mut gnu_source_default = String::new(); + if let BuildType::Autotools = build_type { + // Rely on the initial `is_gnu_project` answer (do not re-ask). + if is_gnu_project { + let suffix = Text::new("GNU tarball suffix:") + .with_help_message("e.g. .tar.gz, .tar.xz") + .with_default(".tar.gz") + .prompt()?; + gnu_source_default = format!( + "https://mirrors.kernel.org/gnu/{name}/{name}-{version}{suffix}", + name = name, + version = version, + suffix = suffix + ); + } + } + + // Ask whether to show advanced fields — if yes, prompt for them later. + let show_advanced = Confirm::new("Show advanced options?") + .with_help_message("Enable prompts for optional build flags and advanced fields") + .with_default(false) + .prompt()?; + + let build_dir = if Confirm::new("Use separate build directory?") + .with_help_message("e.g., build/ or out/") + .with_default(false) + .prompt()? + { + Some( + Text::new("Build directory name:") + .with_default("build") + .prompt()?, + ) + } else { + None + }; + + // Advanced fields (only shown when requested) + let mut cflags = Vec::new(); + let mut ldflags = Vec::new(); + let mut chost = String::new(); + let mut cbuild = String::new(); + let mut carch = String::new(); + let mut bindir = String::new(); + + if show_advanced { + let c = Text::new("CFLAGS (optional, empty to skip):") + .with_help_message("Space-separated CFLAGS") + .prompt()?; + if !c.trim().is_empty() { + cflags = c.split_whitespace().map(String::from).collect(); + } + let l = Text::new("LDFLAGS (optional, empty to skip):") + .with_help_message("Space-separated LDFLAGS") + .prompt()?; + if !l.trim().is_empty() { + ldflags = l.split_whitespace().map(String::from).collect(); + } + chost = Text::new("CHOST (optional):").prompt()?; + cbuild = Text::new("CBUILD (optional):").prompt()?; + carch = Text::new("CARCH (optional):").prompt()?; + bindir = Text::new("Binary install dir (optional, default /usr/bin):") + .with_default("/usr/bin") + .prompt()?; + } + + let source_url = Text::new("Source URL:") + .with_help_message("URL to the source tarball or git repository") + .with_default(gnu_source_default.as_str()) + .prompt()?; + + // Attempt to compute SHA256 automatically when online — use as the default if available. + let computed_sha_default = match compute_sha256_for_url(&source_url) { + Ok(hex) => { + // Use raw hex as the default so pressing Enter accepts it + hex + } + Err(_) => "skip".to_string(), + }; + + let source_sha256 = Text::new("Source checksum:") + .with_help_message("Accepts sha256:, sha512:, md5:, or raw SHA256 hex (use 'skip' to bypass)") + .with_default(computed_sha_default.as_str()) + .prompt()?; + + let extract_dir = Text::new("Extract Directory:") + .with_help_message("Directory created after extraction (supports $name, $version)") + .with_default("$name-$version") + .prompt()?; + + let mut sources = Vec::new(); + if !source_url.is_empty() { + sources.push(Source { + url: source_url, + sha256: source_sha256, + extract_dir, + patches: Vec::new(), + post_extract: Vec::new(), + }); + } + + let mut runtime_deps = Vec::new(); + loop { + let dep = Text::new("Runtime Dependency (empty to finish):").prompt()?; + if dep.is_empty() { + break; + } + runtime_deps.push(dep); + } + + let mut build_deps = Vec::new(); + loop { + let dep = Text::new("Build-time Dependency (empty to finish):").prompt()?; + if dep.is_empty() { + break; + } + build_deps.push(dep); + } + + Ok(PackageSpec { + package: PackageInfo { + name, + version, + revision: 1, + description, + homepage, + license, + }, + packages: Vec::new(), + alternatives: Alternatives::default(), + manual_sources: Vec::new(), + source: sources, + build: Build { + build_type, + flags: BuildFlags { + build_dir, + cflags, + ldflags, + chost, + cbuild, + carch, + bindir: if bindir.is_empty() { BuildFlags::default().bindir } else { bindir }, + ..BuildFlags::default() + }, + }, + dependencies: Dependencies { + build: build_deps, + runtime: runtime_deps, + }, + spec_dir: PathBuf::from("."), + }) +} + +/// Produce a compact TOML string for a spec created interactively. +/// This omits default/empty fields so generated `pkg.toml` is concise. +pub fn spec_to_minimal_toml(spec: &PackageSpec) -> anyhow::Result { + use toml::value::{Table, Value}; + + let mut root = Table::new(); + + // package block + let mut pkg = Table::new(); + pkg.insert("name".into(), Value::String(spec.package.name.clone())); + pkg.insert("version".into(), Value::String(spec.package.version.clone())); + if !spec.package.description.is_empty() { + pkg.insert("description".into(), Value::String(spec.package.description.clone())); + } + if !spec.package.homepage.is_empty() { + pkg.insert("homepage".into(), Value::String(spec.package.homepage.clone())); + } + if !spec.package.license.is_empty() { + pkg.insert("license".into(), Value::String(spec.package.license.clone())); + } + root.insert("package".into(), Value::Table(pkg)); + + // additional package outputs (if any) + if !spec.packages.is_empty() { + let mut arr = Vec::new(); + for p in &spec.packages { + let mut pt = Table::new(); + pt.insert("name".into(), Value::String(p.name.clone())); + pt.insert("version".into(), Value::String(p.version.clone())); + if !p.description.is_empty() { + pt.insert("description".into(), Value::String(p.description.clone())); + } + if !p.homepage.is_empty() { + pt.insert("homepage".into(), Value::String(p.homepage.clone())); + } + if !p.license.is_empty() { + pt.insert("license".into(), Value::String(p.license.clone())); + } + arr.push(Value::Table(pt)); + } + root.insert("packages".into(), Value::Array(arr)); + } + + // sources + if !spec.source.is_empty() { + let mut arr = Vec::new(); + for s in &spec.source { + let mut st = Table::new(); + st.insert("url".into(), Value::String(s.url.clone())); + if !s.sha256.is_empty() && s.sha256.to_lowercase() != "skip" { + st.insert("sha256".into(), Value::String(s.sha256.clone())); + } + let default_extract = format!("{}-{}", spec.package.name, spec.package.version); + if s.extract_dir != default_extract { + st.insert("extract_dir".into(), Value::String(s.extract_dir.clone())); + } + if !s.patches.is_empty() { + st.insert( + "patches".into(), + Value::Array(s.patches.iter().map(|p| Value::String(p.clone())).collect()), + ); + } + if !s.post_extract.is_empty() { + st.insert( + "post_extract".into(), + Value::Array(s.post_extract.iter().map(|p| Value::String(p.clone())).collect()), + ); + } + arr.push(Value::Table(st)); + } + root.insert("source".into(), Value::Array(arr)); + } + + // build (only include set fields) + let mut build_tbl = Table::new(); + build_tbl.insert("type".into(), Value::String(format!("{}", format!("{:?}", spec.build.build_type).to_lowercase()))); + + let mut flags_tbl = Table::new(); + if let Some(bdir) = &spec.build.flags.build_dir { + flags_tbl.insert("build_dir".into(), Value::String(bdir.clone())); + } + if !spec.build.flags.cflags.is_empty() { + flags_tbl.insert( + "cflags".into(), + Value::Array(spec.build.flags.cflags.iter().map(|s| Value::String(s.clone())).collect()), + ); + } + if !spec.build.flags.ldflags.is_empty() { + flags_tbl.insert( + "ldflags".into(), + Value::Array(spec.build.flags.ldflags.iter().map(|s| Value::String(s.clone())).collect()), + ); + } + if !spec.build.flags.chost.is_empty() { + flags_tbl.insert("chost".into(), Value::String(spec.build.flags.chost.clone())); + } + if !spec.build.flags.cbuild.is_empty() { + flags_tbl.insert("cbuild".into(), Value::String(spec.build.flags.cbuild.clone())); + } + if !spec.build.flags.carch.is_empty() && spec.build.flags.carch != BuildFlags::default().carch { + flags_tbl.insert("carch".into(), Value::String(spec.build.flags.carch.clone())); + } + if spec.build.flags.bindir != BuildFlags::default().bindir { + flags_tbl.insert("bindir".into(), Value::String(spec.build.flags.bindir.clone())); + } + + if !flags_tbl.is_empty() { + build_tbl.insert("flags".into(), Value::Table(flags_tbl)); + } + root.insert("build".into(), Value::Table(build_tbl)); + + // dependencies + if !spec.dependencies.build.is_empty() || !spec.dependencies.runtime.is_empty() { + let mut dep_tbl = Table::new(); + if !spec.dependencies.build.is_empty() { + dep_tbl.insert( + "build".into(), + Value::Array(spec.dependencies.build.iter().map(|s| Value::String(s.clone())).collect()), + ); + } + if !spec.dependencies.runtime.is_empty() { + dep_tbl.insert( + "runtime".into(), + Value::Array(spec.dependencies.runtime.iter().map(|s| Value::String(s.clone())).collect()), + ); + } + root.insert("dependencies".into(), Value::Table(dep_tbl)); + } + + Ok(toml::to_string_pretty(&Value::Table(root))?) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::package::{BuildFlags, BuildType, Dependencies, PackageInfo, PackageSpec, Source}; + + #[test] + fn spec_to_minimal_toml_omits_defaults() { + let spec = PackageSpec { + package: PackageInfo { + name: "foo".into(), + version: "1.0".into(), + revision: 1, + description: "A test".into(), + homepage: "".into(), + license: "MIT".into(), + }, + packages: Vec::new(), + alternatives: Alternatives::default(), + manual_sources: Vec::new(), + source: vec![Source { + url: "https://example.com/foo-1.0.tar.gz".into(), + sha256: "skip".into(), + extract_dir: "foo-1.0".into(), + patches: Vec::new(), + post_extract: Vec::new(), + }], + build: Build { + build_type: BuildType::Autotools, + flags: BuildFlags::default(), + }, + dependencies: Dependencies::default(), + spec_dir: PathBuf::from("."), + }; + + let toml = spec_to_minimal_toml(&spec).unwrap(); + assert!(toml.contains("name = \"foo\"")); + assert!(toml.contains("version = \"1.0\"")); + assert!(toml.contains("description = \"A test\"")); + // defaults should not be present + assert!(!toml.contains("cflags")); + assert!(!toml.contains("rustflags")); + // sha256="skip" should be omitted + assert!(!toml.contains("sha256")); + } + + #[test] + fn spec_to_minimal_toml_includes_additional_packages() { + let spec = PackageSpec { + package: PackageInfo { + name: "foo".into(), + version: "1.0".into(), + revision: 1, + description: "A test".into(), + homepage: "".into(), + license: "MIT".into(), + }, + packages: vec![PackageInfo { + name: "foo-dev".into(), + version: "1.0".into(), + revision: 1, + description: "dev files".into(), + homepage: "".into(), + license: "MIT".into(), + }], + alternatives: Alternatives::default(), + manual_sources: Vec::new(), + source: vec![Source { + url: "https://example.com/foo-1.0.tar.gz".into(), + sha256: "skip".into(), + extract_dir: "foo-1.0".into(), + patches: Vec::new(), + post_extract: Vec::new(), + }], + build: Build { + build_type: BuildType::Autotools, + flags: BuildFlags::default(), + }, + dependencies: Dependencies::default(), + spec_dir: PathBuf::from("."), + }; + + let toml = spec_to_minimal_toml(&spec).unwrap(); + assert!(toml.contains("[[packages]]")); + assert!(toml.contains("name = \"foo-dev\"")); + } + + #[test] + fn compute_sha256_for_local_path_and_file_url() { + use tempfile::NamedTempFile; + use sha2::Sha256 as TestSha256; + use sha2::Digest as TestDigest; + + let mut tmp = NamedTempFile::new().unwrap(); + std::io::Write::write_all(&mut tmp, b"abc").unwrap(); + let expected = { + let mut h = TestSha256::new(); + h.update(b"abc"); + format!("{:x}", h.finalize()) + }; + + // plain path + let p = tmp.path().to_str().unwrap().to_string(); + assert_eq!(compute_sha256_for_url(&p).unwrap(), expected); + + // file:// URL + let file_url = format!("file://{}", tmp.path().to_str().unwrap()); + assert_eq!(compute_sha256_for_url(&file_url).unwrap(), expected); + } +} + diff --git a/src/package/mod.rs b/src/package/mod.rs index fb1b6b3..b107c76 100755 --- a/src/package/mod.rs +++ b/src/package/mod.rs @@ -1,7 +1,9 @@ //! Package specification parsing +mod interactive; mod packager; mod spec; +pub use interactive::*; pub use packager::Packager; pub use spec::*; diff --git a/src/package/packager.rs b/src/package/packager.rs index 874b53e..b6e5fd1 100644 --- a/src/package/packager.rs +++ b/src/package/packager.rs @@ -40,13 +40,13 @@ impl Packager { let file = fs::File::create(&output_path) .with_context(|| format!("Failed to create output file: {}", output_path.display()))?; - // Respect zstd level from config (default to 3 if not specified) + // Respect zstd level from config (default to 19 if not specified) let level = self .config .package_overrides .get("compression_level") .and_then(|v| v.as_integer()) - .unwrap_or(3) as i32; + .unwrap_or(19) as i32; let mut encoder = Encoder::new(file, level)?; let _ = encoder.multithread(num_cpus() as u32); @@ -231,6 +231,7 @@ mod tests { homepage: "h".into(), license: "MIT".into(), }, + packages: Vec::new(), alternatives: Alternatives::default(), manual_sources: Vec::new(), source: Vec::new(), diff --git a/src/package/spec.rs b/src/package/spec.rs index c7eebe1..377bf34 100755 --- a/src/package/spec.rs +++ b/src/package/spec.rs @@ -9,9 +9,12 @@ use std::path::Path; use std::path::PathBuf; /// Complete package specification from TOML -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] pub struct PackageSpec { pub package: PackageInfo, + /// Optional additional package outputs produced from the same spec/destdir + #[serde(default)] + pub packages: Vec, #[serde(default)] pub alternatives: Alternatives, /// Manual (local) sources to copy before fetching remote sources. @@ -60,13 +63,21 @@ impl PackageSpec { .replace("$name", &self.package.name) .replace("$version", &self.package.version) .replace("$specdir", &specdir) - .replace("$NYAPM_SPECDIR", &specdir) + .replace("$DEPOT_SPECDIR", &specdir) } pub fn sources(&self) -> &[Source] { &self.source } + /// Return all package outputs this spec will produce (primary + any extras) + pub fn outputs(&self) -> Vec { + let mut v = Vec::new(); + v.push(self.package.clone()); + v.extend(self.packages.clone()); + v + } + /// Apply system configuration overrides and appends pub fn apply_config(&mut self, config: &crate::config::Config) { // Apply build overrides from /etc/depot.d/build.toml @@ -140,6 +151,11 @@ impl PackageSpec { self.build.flags.cbuild = s.to_string(); } } + "carch" => { + if let Some(s) = v.as_str() { + self.build.flags.carch = s.to_string(); + } + } // Add more fields as needed _ => {} } @@ -233,6 +249,11 @@ impl PackageSpec { self.build.flags.cbuild = s.to_string(); } } + "carch" => { + if let Some(s) = values.last().and_then(|v| v.as_str()) { + self.build.flags.carch = s.to_string(); + } + } _ => {} } } @@ -400,6 +421,8 @@ cbuild = "x86_64-pc-linux-gnu" .unwrap(), package_overrides: toml::Value::Table(toml::map::Map::new()), appends: std::collections::HashMap::new(), + mirrors: std::collections::HashMap::new(), + repo_clone_dir: PathBuf::from("/tmp"), }; spec.apply_config(&config); @@ -407,6 +430,21 @@ cbuild = "x86_64-pc-linux-gnu" assert_eq!(spec.build.flags.cbuild, "x86_64-pc-linux-gnu"); } + #[test] + fn test_default_and_override_carch() { + let mut spec = mk_spec("foo", "1.0"); + // Default should be host arch + assert_eq!(spec.build.flags.carch, std::env::consts::ARCH.to_string()); + + // Override via config + let mut config = crate::config::Config::for_rootfs(Path::new("/tmp/nonexistent")); + config.build_overrides = toml::from_str(r#"[flags] +carch = "armv7" +"#).unwrap(); + spec.apply_config(&config); + assert_eq!(spec.build.flags.carch, "armv7"); + } + #[test] fn test_package_filename() { let mut spec = mk_spec("foo", "1.0"); @@ -417,6 +455,46 @@ cbuild = "x86_64-pc-linux-gnu" ); } + #[test] + fn parse_packages_array() { + 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" + +[[packages]] +name = "foo-dev" +version = "1.0" +description = "development files" +homepage = "h" +license = "MIT" + +[[source]] +url = "https://example.com/foo-1.0.tar.gz" +sha256 = "skip" +extract_dir = "foo-1.0" + +[build] +type = "custom" +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + let outputs = spec.outputs(); + assert_eq!(outputs.len(), 2); + assert_eq!(outputs[0].name, "foo"); + assert_eq!(outputs[1].name, "foo-dev"); + } + fn mk_spec(name: &str, version: &str) -> PackageSpec { PackageSpec { package: PackageInfo { @@ -427,6 +505,7 @@ cbuild = "x86_64-pc-linux-gnu" homepage: "h".into(), license: "MIT".into(), }, + packages: Vec::new(), alternatives: Alternatives::default(), manual_sources: Vec::new(), source: vec![Source { @@ -493,21 +572,22 @@ impl PackageSpec { } /// Package alternatives (provides/replaces) -#[derive(Debug, Default, Deserialize)] +#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)] pub struct Alternatives { - #[serde(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub provides: Vec, /// Reserved for future package replacement feature - #[serde(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] #[allow(dead_code)] pub replaces: Vec, } /// Source tarball information -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] pub struct Source { pub url: String, - /// SHA256 checksum or "skip" to bypass + /// Checksum for the source (e.g. `sha256:...`, `sha512:...`, `md5:...`, or raw SHA256 hex). + /// Use `skip` to bypass verification. pub sha256: String, /// Directory name after extraction (supports $name, $version) pub extract_dir: String, @@ -528,7 +608,7 @@ pub struct Source { } /// Manual (local) source file to copy before fetching remote sources. -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] pub struct ManualSource { /// Filename in the spec directory pub file: String, @@ -561,7 +641,7 @@ where } /// Build configuration -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] pub struct Build { #[serde(rename = "type")] pub build_type: BuildType, @@ -570,7 +650,7 @@ pub struct Build { } /// Supported build systems -#[derive(Debug, Deserialize, Clone, Copy)] +#[derive(Debug, serde::Deserialize, serde::Serialize, Clone, Copy)] #[serde(rename_all = "lowercase")] pub enum BuildType { Autotools, @@ -578,12 +658,12 @@ pub enum BuildType { Meson, Custom, Rust, - /// Binary distribution installer (e.g., .deb, .rpm) + Makefile, Bin, } /// Build flags and toolchain configuration -#[derive(Debug, Default, Deserialize)] +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] pub struct BuildFlags { #[serde(default, deserialize_with = "deserialize_string_or_array")] pub cflags: Vec, @@ -611,6 +691,12 @@ pub struct BuildFlags { #[serde(default)] pub post_install: Vec, + /// Specific commands for 'makefile' build type + #[serde(default)] + pub makefile_commands: Vec, + #[serde(default)] + pub makefile_install_commands: Vec, + /// Installation prefix (default: /usr) #[serde(default = "default_prefix")] pub prefix: String, @@ -623,6 +709,10 @@ pub struct BuildFlags { #[serde(default)] pub cbuild: String, + /// CPU architecture short name (CARCH equivalent), e.g. "x86_64", "aarch64" + #[serde(default = "default_carch")] + pub carch: String, + // Rust-specific fields /// Rust build profile: "debug" or "release" (default: release) #[serde(default = "default_profile")] @@ -644,11 +734,44 @@ pub struct BuildFlags { /// Useful for monorepos like llvm-project where you want to build just one component. #[serde(default)] pub source_subdir: String, + /// Build directory relative to source root (e.g. "build") + #[serde(default, skip_serializing_if = "Option::is_none")] + pub build_dir: Option, /// Binary package type when using BuildType::Bin (e.g. "deb") #[serde(default)] pub binary_type: String, } +impl Default for BuildFlags { + fn default() -> Self { + BuildFlags { + cflags: Vec::new(), + ldflags: Vec::new(), + configure: Vec::new(), + cc: default_cc(), + ar: default_ar(), + libc: String::new(), + rootfs: default_rootfs(), + post_compile: Vec::new(), + post_install: Vec::new(), + makefile_commands: Vec::new(), + makefile_install_commands: Vec::new(), + prefix: default_prefix(), + chost: String::new(), + cbuild: String::new(), + carch: default_carch(), + profile: default_profile(), + target: String::new(), + rustflags: Vec::new(), + cargs: Vec::new(), + bindir: default_bindir(), + source_subdir: String::new(), + build_dir: None, + binary_type: String::new(), + } + } +} + fn deserialize_string_or_array<'de, D>( deserializer: D, ) -> std::result::Result, D::Error> @@ -702,8 +825,12 @@ fn default_prefix() -> String { "/usr".to_string() } +fn default_carch() -> String { + std::env::consts::ARCH.to_string() +} + /// Package dependencies -#[derive(Debug, Default, Deserialize)] +#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)] pub struct Dependencies { #[serde(default)] pub build: Vec, diff --git a/src/source/extractor.rs b/src/source/extractor.rs index 843afaa..bbdd474 100755 --- a/src/source/extractor.rs +++ b/src/source/extractor.rs @@ -7,6 +7,8 @@ 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 walkdir::WalkDir; use zstd::stream::read::Decoder as ZstdDecoder; /// Extract an archive source to the build directory. @@ -36,34 +38,34 @@ pub fn extract_archive( println!("Extracting: {}", filename); if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") { - extract_tar_gz(archive_path, build_dir)?; + extract_tar_gz(archive_path, &extract_path)?; } else if filename.ends_with(".tar.xz") || filename.ends_with(".txz") { - extract_tar_xz(archive_path, build_dir)?; + extract_tar_xz(archive_path, &extract_path)?; } else if filename.ends_with(".tar.bz2") || filename.ends_with(".tbz2") { - extract_tar_bz2(archive_path, build_dir)?; + extract_tar_bz2(archive_path, &extract_path)?; } else if filename.ends_with(".tar.zst") || filename.ends_with(".tzst") { - extract_tar_zst(archive_path, build_dir)?; + extract_tar_zst(archive_path, &extract_path)?; } else if filename.ends_with(".zip") { - extract_zip(archive_path, build_dir)?; + extract_zip(archive_path, &extract_path)?; } else if filename.ends_with(".tar") { - extract_tar(archive_path, build_dir)?; + extract_tar(archive_path, &extract_path)?; } else if filename.ends_with(".deb") { - extract_deb(archive_path, build_dir)?; + extract_deb(archive_path, &extract_path)?; } else if filename.ends_with(".rpm") { - extract_rpm(archive_path, build_dir)?; + extract_rpm(archive_path, &extract_path)?; } else if filename.ends_with(".gz") { - extract_gz_file(archive_path, build_dir)?; + extract_gz_file(archive_path, &extract_path)?; } else if filename.ends_with(".xz") { - extract_xz_file(archive_path, build_dir)?; + extract_xz_file(archive_path, &extract_path)?; } else if filename.ends_with(".zst") { - extract_zst_file(archive_path, build_dir)?; + extract_zst_file(archive_path, &extract_path)?; } else { bail!("Unsupported archive format: {}", filename); } if !extract_path.exists() { bail!( - "Expected extraction directory not found: {}", + "Extraction did not create expected path: {}", extract_path.display() ); } @@ -73,48 +75,274 @@ pub fn extract_archive( } fn extract_tar_gz(path: &Path, dest: &Path) -> Result<()> { + let tmp = tempdir()?; let file = File::open(path)?; let decoder = GzDecoder::new(file); let mut archive = tar::Archive::new(decoder); - archive.unpack(dest)?; + archive.unpack(tmp.path())?; + + // If the archive produced a single top-level directory, decide whether to + // 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::>(); + 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(""); + + // 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", + ]; + + 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)) + || looks_like_versioned(&top_name)); + + if should_strip { + // strip the single top-level folder (move its contents into dest) + move_dir_contents(&top[0].path(), dest)?; + } else { + // preserve the top-level folder (move the directory itself under dest) + fs::create_dir_all(dest)?; + let dest_top = dest.join(top_name); + if fs::rename(&top[0].path(), &dest_top).is_err() { + copy_dir_recursive_local(&top[0].path(), &dest_top)?; + fs::remove_dir_all(&top[0].path())?; + } + } + } else { + move_dir_contents(tmp.path(), dest)?; + } Ok(()) } fn extract_tar_xz(path: &Path, dest: &Path) -> Result<()> { + let tmp = tempdir()?; let file = File::open(path)?; let decoder = xz2::read::XzDecoder::new(file); let mut archive = tar::Archive::new(decoder); - archive.unpack(dest)?; + archive.unpack(tmp.path())?; + + let top = fs::read_dir(tmp.path())?.filter_map(|r| r.ok()).collect::>(); + if top.len() == 1 && top[0].path().is_dir() { + let top_name = top[0].file_name().to_string_lossy().to_string(); + let expected_basename = dest + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + + // 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", + ]; + + 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)) + || looks_like_versioned(&top_name)); + + if should_strip { + // strip the single top-level folder (move its contents into dest) + move_dir_contents(&top[0].path(), dest)?; + } else { + // preserve the top-level folder (move the directory itself under dest) + fs::create_dir_all(dest)?; + let dest_top = dest.join(top_name); + if fs::rename(&top[0].path(), &dest_top).is_err() { + copy_dir_recursive_local(&top[0].path(), &dest_top)?; + fs::remove_dir_all(&top[0].path())?; + } + } + } else { + move_dir_contents(tmp.path(), dest)?; + } Ok(()) } fn extract_tar_bz2(path: &Path, dest: &Path) -> Result<()> { + let tmp = tempdir()?; let file = File::open(path)?; let decoder = bzip2::read::BzDecoder::new(file); let mut archive = tar::Archive::new(decoder); - archive.unpack(dest)?; + archive.unpack(tmp.path())?; + + let top = fs::read_dir(tmp.path())?.filter_map(|r| r.ok()).collect::>(); + if top.len() == 1 && top[0].path().is_dir() { + let top_name = top[0].file_name().to_string_lossy().to_string(); + let expected_basename = dest + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + + // 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", + ]; + + 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)) + || looks_like_versioned(&top_name)); + + if should_strip { + // strip the single top-level folder (move its contents into dest) + move_dir_contents(&top[0].path(), dest)?; + } else { + // preserve the top-level folder (move the directory itself under dest) + fs::create_dir_all(dest)?; + let dest_top = dest.join(top_name); + if fs::rename(&top[0].path(), &dest_top).is_err() { + copy_dir_recursive_local(&top[0].path(), &dest_top)?; + fs::remove_dir_all(&top[0].path())?; + } + } + } else { + move_dir_contents(tmp.path(), dest)?; + } Ok(()) } fn extract_tar(path: &Path, dest: &Path) -> Result<()> { + let tmp = tempdir()?; let file = File::open(path)?; let mut archive = tar::Archive::new(file); - archive.unpack(dest)?; + archive.unpack(tmp.path())?; + + let top = fs::read_dir(tmp.path())?.filter_map(|r| r.ok()).collect::>(); + if top.len() == 1 && top[0].path().is_dir() { + let top_name = top[0].file_name().to_string_lossy().to_string(); + let expected_basename = dest + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + + // 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", + ]; + + 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)) + || looks_like_versioned(&top_name)); + + if should_strip { + // strip the single top-level folder (move its contents into dest) + move_dir_contents(&top[0].path(), dest)?; + } else { + // preserve the top-level folder (move the directory itself under dest) + fs::create_dir_all(dest)?; + let dest_top = dest.join(top_name); + if fs::rename(&top[0].path(), &dest_top).is_err() { + copy_dir_recursive_local(&top[0].path(), &dest_top)?; + fs::remove_dir_all(&top[0].path())?; + } + } + } else { + move_dir_contents(tmp.path(), dest)?; + } Ok(()) } fn extract_zip(path: &Path, dest: &Path) -> Result<()> { + let tmp = tempdir()?; let file = File::open(path)?; let mut archive = zip::ZipArchive::new(file)?; - archive.extract(dest)?; + archive.extract(tmp.path())?; + + let top = fs::read_dir(tmp.path())?.filter_map(|r| r.ok()).collect::>(); + if top.len() == 1 && top[0].path().is_dir() { + let top_name = top[0].file_name().to_string_lossy().to_string(); + let expected_basename = dest + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + + // 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", + ]; + + 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)) + || looks_like_versioned(&top_name)); + + if should_strip { + // strip the single top-level folder (move its contents into dest) + move_dir_contents(&top[0].path(), dest)?; + } else { + // preserve the top-level folder (move the directory itself under dest) + fs::create_dir_all(dest)?; + let dest_top = dest.join(top_name); + if fs::rename(&top[0].path(), &dest_top).is_err() { + copy_dir_recursive_local(&top[0].path(), &dest_top)?; + fs::remove_dir_all(&top[0].path())?; + } + } + } else { + move_dir_contents(tmp.path(), dest)?; + } Ok(()) } fn extract_tar_zst(path: &Path, dest: &Path) -> Result<()> { + let tmp = tempdir()?; let file = File::open(path)?; let decoder = ZstdDecoder::new(file)?; let mut archive = tar::Archive::new(decoder); - archive.unpack(dest)?; + archive.unpack(tmp.path())?; + + let top = fs::read_dir(tmp.path())?.filter_map(|r| r.ok()).collect::>(); + if top.len() == 1 && top[0].path().is_dir() { + let top_name = top[0].file_name().to_string_lossy().to_string(); + let expected_basename = dest + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + + // 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", + ]; + + 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)) + || looks_like_versioned(&top_name)); + + if should_strip { + // strip the single top-level folder (move its contents into dest) + move_dir_contents(&top[0].path(), dest)?; + } else { + // preserve the top-level folder (move the directory itself under dest) + fs::create_dir_all(dest)?; + let dest_top = dest.join(top_name); + if fs::rename(&top[0].path(), &dest_top).is_err() { + copy_dir_recursive_local(&top[0].path(), &dest_top)?; + fs::remove_dir_all(&top[0].path())?; + } + } + } else { + move_dir_contents(tmp.path(), dest)?; + } Ok(()) } @@ -186,38 +414,32 @@ fn extract_deb(path: &Path, dest: &Path) -> Result<()> { let file = File::open(path)?; let mut ar = ar::Archive::new(file); - // Iterate members and look for data.tar, data.tar.gz, data.tar.xz, data.tar.zst while let Some(entry_result) = ar.next_entry() { - let entry = entry_result?; + let mut entry = entry_result?; let id = String::from_utf8_lossy(entry.header().identifier()).to_string(); let lower = id.to_ascii_lowercase(); if lower.starts_with("data.tar") { - // Determine compression + // write the inner member to a temporary file and reuse tar extraction logic + let mut tmpf = NamedTempFile::new()?; + std::io::copy(&mut entry, &mut tmpf)?; + let tmp_path = tmpf.path().to_path_buf(); + if lower.ends_with(".gz") { - let decoder = GzDecoder::new(entry); - let mut archive = tar::Archive::new(decoder); - archive.unpack(dest)?; + extract_tar_gz(&tmp_path, dest)?; return Ok(()); } else if lower.ends_with(".xz") { - let decoder = xz2::read::XzDecoder::new(entry); - let mut archive = tar::Archive::new(decoder); - archive.unpack(dest)?; + extract_tar_xz(&tmp_path, dest)?; return Ok(()); } else if lower.ends_with(".zst") { - let decoder = ZstdDecoder::new(entry)?; - let mut archive = tar::Archive::new(decoder); - archive.unpack(dest)?; + extract_tar_zst(&tmp_path, dest)?; return Ok(()); } else { - // plain tar - let mut archive = tar::Archive::new(entry); - archive.unpack(dest)?; + extract_tar(&tmp_path, dest)?; return Ok(()); } } } - // No data member found anyhow::bail!("No data.tar.* member found in deb: {}", path.display()); } @@ -334,27 +556,34 @@ fn extract_rpm(path: &Path, dest: &Path) -> Result<()> { let zst_sig = b"\x28\xb5\x2f\xfd"; let cpio_sig = b"070701"; + // Extract into a temporary directory first, then move/strip into `dest`. + let tmp = tempdir()?; + if let Some(pos) = find_subslice(&data, gz_sig) { let cursor = Cursor::new(&data[pos..]); let decoder = GzDecoder::new(cursor); - extract_cpio_newc_from_reader(decoder, dest)?; + extract_cpio_newc_from_reader(decoder, tmp.path())?; + move_dir_contents(tmp.path(), dest)?; return Ok(()); } if let Some(pos) = find_subslice(&data, xz_sig) { let cursor = Cursor::new(&data[pos..]); let decoder = xz2::read::XzDecoder::new(cursor); - extract_cpio_newc_from_reader(decoder, dest)?; + extract_cpio_newc_from_reader(decoder, tmp.path())?; + move_dir_contents(tmp.path(), dest)?; return Ok(()); } if let Some(pos) = find_subslice(&data, zst_sig) { let cursor = Cursor::new(&data[pos..]); let decoder = ZstdDecoder::new(cursor)?; - extract_cpio_newc_from_reader(decoder, dest)?; + extract_cpio_newc_from_reader(decoder, tmp.path())?; + move_dir_contents(tmp.path(), dest)?; return Ok(()); } if let Some(pos) = find_subslice(&data, cpio_sig) { let cursor = Cursor::new(&data[pos..]); - extract_cpio_newc_from_reader(cursor, dest)?; + extract_cpio_newc_from_reader(cursor, tmp.path())?; + move_dir_contents(tmp.path(), dest)?; return Ok(()); } @@ -368,6 +597,48 @@ fn find_subslice(hay: &[u8], needle: &[u8]) -> Option { hay.windows(needle.len()).position(|w| w == needle) } +/// Move contents of `src` into `dest`. If `src` contains exactly one directory +/// and `strip_single_top` is intended, callers can pass the inner dir instead. +fn move_dir_contents(src: &Path, dest: &Path) -> Result<()> { + fs::create_dir_all(dest)?; + for entry in fs::read_dir(src)? { + let entry = entry?; + let src_path = entry.path(); + let dest_path = dest.join(entry.file_name()); + if fs::rename(&src_path, &dest_path).is_err() { + // fallback to copy-and-remove across filesystems + if src_path.is_dir() { + copy_dir_recursive_local(&src_path, &dest_path)?; + fs::remove_dir_all(&src_path)?; + } else { + fs::copy(&src_path, &dest_path)?; + fs::remove_file(&src_path)?; + } + } + } + Ok(()) +} + +fn copy_dir_recursive_local(src: &Path, dst: &Path) -> Result<()> { + for entry in WalkDir::new(src) { + let entry = entry?; + let rel = entry.path().strip_prefix(src).unwrap(); + let target = dst.join(rel); + if entry.file_type().is_dir() { + fs::create_dir_all(&target)?; + } else if entry.file_type().is_symlink() { + let target_link = fs::read_link(entry.path())?; + unix_fs::symlink(target_link, &target)?; + } else { + if let Some(parent) = target.parent() { + fs::create_dir_all(parent)?; + } + fs::copy(entry.path(), &target)?; + } + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/source/fetcher.rs b/src/source/fetcher.rs index 24b9c46..9d2c0a4 100755 --- a/src/source/fetcher.rs +++ b/src/source/fetcher.rs @@ -27,13 +27,82 @@ pub fn fetch_archive(spec: &PackageSpec, source: &Source, cache_dir: &Path) -> R println!("Fetching: {}", url); + // 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 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()) + .with_context(|| format!("Failed to connect to FTP host: {}", addr))?; + 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))?; + + // 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(()) + }) { + Ok(_) => { + retrieved = true; + break; + } + Err(_) => continue, + } + } + ftp_stream.quit().ok(); + if !retrieved { + bail!("FTP error fetching {}", url); + } + } + // Download with progress bar - let client = reqwest::blocking::Client::new(); + // 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 mut response = client .get(&url) .send() .with_context(|| format!("Failed to fetch: {}", url))?; - + // If the server returned a non-success status, read a short body preview and fail early. + // This prevents saving HTML error pages (which then fail checksum) and gives a clearer + // diagnostic to the user. + let status = response.status(); + if !status.is_success() { + let mut preview_bytes = Vec::new(); + // read up to 1 KiB for a preview (ignore errors while reading preview) + let _ = response.take(1024).read_to_end(&mut preview_bytes); + let preview = String::from_utf8_lossy(&preview_bytes); + bail!( + "HTTP error fetching {}: {}{}", + url, + status, + if preview.trim().is_empty() { + "".to_string() + } else { + format!(" — preview: {}", preview.trim()) + } + ); + } let total_size = response.content_length().unwrap_or(0); let pb = ProgressBar::new(total_size); pb.set_style( @@ -61,6 +130,92 @@ pub fn fetch_archive(spec: &PackageSpec, source: &Source, cache_dir: &Path) -> R pb.finish_with_message("Download complete"); + // Quick validation: ensure the downloaded file looks like the expected + // archive (detect obvious HTML error pages or wrong formats by magic). + // 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)? { + 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 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()) + .with_context(|| format!("Failed to connect to FTP host: {}", addr))?; + 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))?; + + 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(()) + }) { + Ok(_) => { + retrieved = true; + break; + } + Err(_) => continue, + } + } + ftp_stream.quit().ok(); + if !retrieved { + bail!("FTP mirror error fetching {}", alt); + } + } 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() + .with_context(|| "Failed to build HTTP client")?; + let mut response = client + .get(&alt) + .send() + .with_context(|| format!("Failed to fetch mirror URL: {}", alt))?; + + let mut file = File::create(&dest_path) + .with_context(|| format!("Failed to create: {}", dest_path.display()))?; + let mut buffer = [0u8; 8192]; + let mut downloaded = 0u64; + loop { + let bytes_read = response.read(&mut buffer)?; + if bytes_read == 0 { + break; + } + file.write_all(&buffer[..bytes_read])?; + downloaded += bytes_read as u64; + pb.set_position(downloaded); + } + } + } else { + // Fallback: unknown/malformed alt URL -> bail + bail!("Malformed mirror URL: {}", alt); + } + + pb.finish_with_message("Download complete (mirror)"); + + // Re-validate the mirrored file + validate_downloaded_archive(&dest_path, &filename, &alt)?; + } + // Verify checksum if !verify_checksum(&dest_path, &source.sha256)? { fs::remove_file(&dest_path)?; @@ -71,32 +226,20 @@ pub fn fetch_archive(spec: &PackageSpec, source: &Source, cache_dir: &Path) -> R Ok(dest_path) } -/// Verify SHA256 checksum of a file +/// Verify checksum of a file supporting optional algorithm prefix. +/// +/// Supported formats: +/// - `sha256:` (or just `` — default) +/// - `sha512:` +/// - `md5:` +/// - `skip` to bypass verification fn verify_checksum(path: &Path, expected: &str) -> Result { - // Skip verification if requested - if expected.to_lowercase() == "skip" { - println!("Checksum verification skipped"); - return Ok(true); - } - - let mut file = File::open(path)?; - let mut hasher = Sha256::new(); - let mut buffer = [0u8; 8192]; - - loop { - let bytes_read = file.read(&mut buffer)?; - if bytes_read == 0 { - break; - } - hasher.update(&buffer[..bytes_read]); - } - - let result = hasher.finalize(); - let actual = format!("{:x}", result); - - Ok(actual == expected.to_lowercase()) + // Delegate to the shared checker in the parent `source` module. + // The helper also understands the same string forms and "skip". + super::verify_file_hash(path, expected) } + /// Derive a stable filename from a URL. /// /// Rules: @@ -122,6 +265,126 @@ fn derive_filename_from_url(url: &str) -> String { format!("source-{}.download", &hex[..12]) } +/// 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> { + use std::io::Read; + let mut f = std::fs::File::open(path)?; + let mut buf = [0u8; 4096]; + let n = f.read(&mut buf)?; + let head = &buf[..n.min(4096)]; + + // Detect obvious HTML error pages (case-insensitive) + let head_str = String::from_utf8_lossy(head).to_ascii_lowercase(); + if head_str.starts_with(" Option { + if let Some(start) = body[..pos].rfind("href=\"") { + let rest = &body[start + 6..]; + if let Some(end) = rest.find('"') { + return Some(rest[..end].to_string()); + } + } + if let Some(start) = body[..pos].rfind("href='") { + let rest = &body[start + 6..]; + if let Some(end) = rest.find('\'') { + return Some(rest[..end].to_string()); + } + } + None + }; + + // look for downloads.sourceforge.net links in HTML + if let Some(pos) = body.find("downloads.sourceforge.net") { + if let Some(href) = find_href_before(&body, pos) { + let alt = if href.starts_with("//") { + format!("https:{}", href) + } else if href.starts_with("/") { + format!("https://downloads.sourceforge.net{}", href) + } else { + href + }; + return Ok(Some(alt)); + } + } + + // 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() + ); + } + + // 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") { + 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") { + head.starts_with(&[0x1F, 0x8B]) + } else if lower.ends_with(".tar.zst") || lower.ends_with(".tzst") || lower.ends_with(".zst") { + head.starts_with(&[0x28, 0xB5, 0x2F, 0xFD]) + } else if lower.ends_with(".zip") { + head.starts_with(b"PK\x03\x04") + } else if lower.ends_with(".tar.bz2") || lower.ends_with(".tbz2") { + head.starts_with(&[0x42, 0x5A, 0x68]) + } else if lower.ends_with(".tar") { + // check for ustar magic at offset 257 + 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 + } + } else { + true + } + } else if lower.ends_with(".deb") { + // ar archive starts with "!\n" + head.starts_with(b"!") + } else if lower.ends_with(".rpm") { + // rpm contains cpio magic later; best-effort: accept + true + } else { + // Unknown extension -> be permissive + 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)] mod tests { use super::*; @@ -148,9 +411,89 @@ mod tests { assert!(name.starts_with("source-") && name.ends_with(".download")); } + #[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"); + } + #[test] fn filename_from_non_url_string() { let name = derive_filename_from_url("not-a-url-at-all"); assert!(name.starts_with("source-") && name.ends_with(".download")); } + + #[test] + fn sourceforge_html_no_link_falls_back_to_download_suffix() { + use std::io::Write; + let mut tmp = tempfile::NamedTempFile::new().unwrap(); + write!(tmp, "No direct link").unwrap(); + let alt = validate_downloaded_archive( + tmp.path(), + "zsh-5.9.tar.xz", + "https://sourceforge.net/projects/zsh/files/zsh/5.9/zsh-5.9.tar.xz", + ) + .unwrap(); + assert_eq!( + alt, + Some( + "https://sourceforge.net/projects/zsh/files/zsh/5.9/zsh-5.9.tar.xz/download" + .to_string() + ) + ); + } + + #[test] + fn sourceforge_html_single_quoted_href_extracts_downloads_link() { + use std::io::Write; + let mut tmp = tempfile::NamedTempFile::new().unwrap(); + write!(tmp, "download").unwrap(); + let alt = validate_downloaded_archive( + tmp.path(), + "zsh-5.9.tar.xz", + "https://sourceforge.net/projects/zsh/files/zsh/5.9/zsh-5.9.tar.xz", + ) + .unwrap(); + assert_eq!( + alt, + Some("https://downloads.sourceforge.net/project/zsh/zsh/5.9/zsh-5.9.tar.xz?download".to_string()) + ); + } + + #[test] + fn verify_checksum_accepts_md5_sha512_and_default_sha256() { + use sha2::Digest; + use sha2::Sha256; + use sha2::Sha512; + + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), b"abc").unwrap(); + + // compute expected values using the same libraries + let sha256_hex = { + let mut h = Sha256::new(); + h.update(b"abc"); + format!("{:x}", h.finalize()) + }; + + let sha512_hex = { + let mut h = Sha512::new(); + h.update(b"abc"); + format!("{:x}", h.finalize()) + }; + + let md5_hex = format!("{:x}", md5::compute(b"abc")); + + // unprefixed should default to sha256 + assert!(verify_checksum(tmp.path(), &sha256_hex).unwrap()); + // explicit prefixes + assert!(verify_checksum(tmp.path(), &format!("sha256:{}", sha256_hex)).unwrap()); + assert!(verify_checksum(tmp.path(), &format!("sha512:{}", sha512_hex)).unwrap()); + assert!(verify_checksum(tmp.path(), &format!("md5:{}", md5_hex)).unwrap()); + // empty algorithm before colon -> assume sha256 + assert!(verify_checksum(tmp.path(), &format!(":{}", sha256_hex)).unwrap()); + // negative: wrong value fails + assert!(!verify_checksum(tmp.path(), "md5:deadbeef").unwrap()); + } } + + diff --git a/src/source/hooks.rs b/src/source/hooks.rs index 4a25057..52f8ed5 100755 --- a/src/source/hooks.rs +++ b/src/source/hooks.rs @@ -8,6 +8,8 @@ use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use std::process::Command; +use crate::builder::state::{BuildStep, StateTracker}; + /// Apply patches and run `post_extract` commands in the extracted source tree. pub fn post_extract( spec: &PackageSpec, @@ -15,8 +17,18 @@ pub fn post_extract( src_dir: &Path, cache_dir: &Path, ) -> Result<()> { - apply_patches(spec, source, src_dir, cache_dir)?; - run_post_extract_commands(spec, source, src_dir)?; + let mut state = StateTracker::new(src_dir)?; + + if !state.is_done(BuildStep::PatchesApplied) { + apply_patches(spec, source, src_dir, cache_dir)?; + state.mark_done(BuildStep::PatchesApplied)?; + } + + if !state.is_done(BuildStep::PostExtractDone) { + run_post_extract_commands(spec, source, src_dir)?; + state.mark_done(BuildStep::PostExtractDone)?; + } + Ok(()) } @@ -80,7 +92,7 @@ fn run_post_extract_commands(spec: &PackageSpec, source: &Source, src_dir: &Path // Use a shell for convenience; this is a package manager, so specs are trusted input. let status = Command::new("sh") .current_dir(src_dir) - .env("NYAPM_SPECDIR", &spec.spec_dir) + .env("DEPOT_SPECDIR", &spec.spec_dir) .arg("-c") .arg(&cmd) .status() @@ -109,8 +121,9 @@ pub fn run_post_compile_commands(spec: &PackageSpec, src_dir: &Path, destdir: &P let status = Command::new("sh") .current_dir(src_dir) - .env("NYAPM_SPECDIR", &spec.spec_dir) + .env("DEPOT_SPECDIR", &spec.spec_dir) .env("DESTDIR", destdir) + .env("DEPOT_ROOTFS", &spec.build.flags.rootfs) .env("CC", &spec.build.flags.cc) .env("AR", &spec.build.flags.ar) .arg("-c") @@ -141,8 +154,9 @@ pub fn run_post_install_commands(spec: &PackageSpec, src_dir: &Path, destdir: &P let status = Command::new("sh") .current_dir(src_dir) - .env("NYAPM_SPECDIR", &spec.spec_dir) + .env("DEPOT_SPECDIR", &spec.spec_dir) .env("DESTDIR", destdir) + .env("DEPOT_ROOTFS", &spec.build.flags.rootfs) .env("CC", &spec.build.flags.cc) .env("AR", &spec.build.flags.ar) .arg("-c") @@ -241,6 +255,7 @@ mod tests { homepage: "h".into(), license: "MIT".into(), }, + packages: Vec::new(), alternatives: Alternatives::default(), manual_sources: Vec::new(), source: vec![Source { diff --git a/src/source/mod.rs b/src/source/mod.rs index 72a1f54..7e5c6a2 100755 --- a/src/source/mod.rs +++ b/src/source/mod.rs @@ -9,7 +9,6 @@ use crate::package::PackageSpec; use anyhow::{Context, Result, bail}; use sha2::{Digest, Sha256}; use std::fs; -use std::io::Read; use std::os::unix::fs as unix_fs; use std::path::{Path, PathBuf}; use walkdir::WalkDir; @@ -36,15 +35,13 @@ pub fn copy_manual_sources(spec: &PackageSpec, build_dir: &Path) -> Result<()> { ); } - // Verify SHA256 if provided (unless "skip") + // Verify checksum if provided (supports `sha256:...`, `sha512:...`, `md5:...`, or raw SHA256). if let Some(expected_hash) = manual.sha256.as_ref().filter(|h| *h != "skip") { - let actual_hash = compute_sha256(&src_path)?; - if actual_hash != *expected_hash { + if !verify_file_hash(&src_path, expected_hash)? { bail!( - "SHA256 mismatch for {}: expected {}, got {}", + "Checksum mismatch for {}: expected {}", manual.file, - expected_hash, - actual_hash + expected_hash ); } } @@ -71,18 +68,76 @@ pub fn copy_manual_sources(spec: &PackageSpec, build_dir: &Path) -> Result<()> { Ok(()) } -fn compute_sha256(path: &Path) -> Result { - let mut file = fs::File::open(path)?; - let mut hasher = Sha256::new(); - let mut buffer = [0u8; 8192]; - loop { - let n = file.read(&mut buffer)?; - if n == 0 { - break; - } - hasher.update(&buffer[..n]); +/// Verify a file against an `expected` checksum string. +/// +/// Formats accepted: `sha256:HEX`, `sha512:HEX`, `md5:HEX`, or plain `HEX` (assumed sha256). +fn verify_file_hash(path: &Path, expected: &str) -> Result { + use anyhow::bail; + use std::io::Read; + + let exp = expected.trim(); + if exp.eq_ignore_ascii_case("skip") { + println!("Checksum verification skipped"); + return Ok(true); + } + + // parse `alg:hex` or default to sha256 when no algorithm given + 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 }; + (alg, h.to_string()) + } else { + ("sha256".to_string(), exp.to_ascii_lowercase()) + }; + + match alg.as_str() { + "sha256" => { + let mut f = fs::File::open(path)?; + let mut hasher = Sha256::new(); + let mut buf = [0u8; 8192]; + loop { + let n = f.read(&mut buf)?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + let actual = format!("{:x}", hasher.finalize()); + Ok(actual == hex) + } + "sha512" => { + use sha2::Sha512; + let mut f = fs::File::open(path)?; + let mut hasher = Sha512::new(); + let mut buf = [0u8; 8192]; + loop { + let n = f.read(&mut buf)?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + let actual = format!("{:x}", hasher.finalize()); + Ok(actual == hex) + } + "md5" => { + let mut ctx = md5::Context::new(); + let mut f = fs::File::open(path)?; + let mut buf = [0u8; 8192]; + loop { + let n = f.read(&mut buf)?; + if n == 0 { + break; + } + ctx.consume(&buf[..n]); + } + let digest = ctx.finalize(); + let actual = format!("{:x}", digest); + Ok(actual == hex) + } + _ => bail!("Unsupported checksum algorithm: {}", alg), } - Ok(format!("{:x}", hasher.finalize())) } /// Fetch + extract all sources. @@ -128,12 +183,33 @@ fn prepare_one( if local_path.is_dir() { let dst = build_dir.join(&extract_dir_name); if dst.exists() { + // If it exists and has a state file, assume we are resuming + if dst.join(".depot_state").exists() { + println!( + "Resuming build in existing source directory: {}", + dst.display() + ); + return Ok(dst); + } fs::remove_dir_all(&dst)?; } copy_dir_recursive(&local_path, &dst)?; hooks::post_extract(spec, source, &dst, cache_dir)?; return Ok(dst); } else if local_path.is_file() { + let src_dir = build_dir.join(&extract_dir_name); + if src_dir.exists() { + if src_dir.join(".depot_state").exists() { + println!( + "Resuming build in existing source directory: {}", + src_dir.display() + ); + return Ok(src_dir); + } + // If no state file, or we want a fresh start, we'd delete it. + // For now, let's just delete if no state file. + fs::remove_dir_all(&src_dir)?; + } let src_dir = extractor::extract_archive(&local_path, spec, source, build_dir)?; hooks::post_extract(spec, source, &src_dir, cache_dir)?; return Ok(src_dir); @@ -146,6 +222,13 @@ fn prepare_one( // (except when it clearly looks like an archive URL) if let Some((base, rev)) = split_git_url(&url) { let checkout_dir = build_dir.join(&extract_dir_name); + if checkout_dir.exists() && checkout_dir.join(".depot_state").exists() { + println!( + "Resuming build in existing git directory: {}", + checkout_dir.display() + ); + return Ok(checkout_dir); + } git::checkout( &base, &rev, @@ -157,6 +240,15 @@ fn prepare_one( return Ok(checkout_dir); } + let src_dir = build_dir.join(&extract_dir_name); + if src_dir.exists() && src_dir.join(".depot_state").exists() { + println!( + "Resuming build in existing source directory: {}", + src_dir.display() + ); + return Ok(src_dir); + } + let archive_path = fetcher::fetch_archive(spec, source, cache_dir)?; let src_dir = extractor::extract_archive(&archive_path, spec, source, build_dir)?; hooks::post_extract(spec, source, &src_dir, cache_dir)?; @@ -218,7 +310,7 @@ fn split_git_url(url: &str) -> Option<(String, String)> { #[cfg(test)] mod tests { - use super::split_git_url; + use super::*; #[test] fn split_git_url_accepts_git_with_rev() { @@ -246,4 +338,32 @@ mod tests { assert_eq!(base, "https://example.com/repo.git"); assert_eq!(rev, "HEAD"); } + + #[test] + fn verify_file_hash_accepts_multiple_algorithms() { + use sha2::{Digest, Sha256, Sha512}; + + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), b"abc").unwrap(); + + let sha256_hex = { + let mut h = Sha256::new(); + h.update(b"abc"); + format!("{:x}", h.finalize()) + }; + let sha512_hex = { + let mut h = Sha512::new(); + h.update(b"abc"); + format!("{:x}", h.finalize()) + }; + let md5_hex = format!("{:x}", md5::compute(b"abc")); + + assert!(verify_file_hash(tmp.path(), &sha256_hex).unwrap()); + assert!(verify_file_hash(tmp.path(), &format!("sha256:{}", sha256_hex)).unwrap()); + assert!(verify_file_hash(tmp.path(), &format!("sha512:{}", sha512_hex)).unwrap()); + assert!(verify_file_hash(tmp.path(), &format!("md5:{}", md5_hex)).unwrap()); + assert!(verify_file_hash(tmp.path(), &format!(":{}", sha256_hex)).unwrap()); + assert!(!verify_file_hash(tmp.path(), "md5:deadbeef").unwrap()); + } } +