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.
This commit is contained in:
2026-02-16 21:38:05 -06:00
parent 00ca2ebac6
commit 9a00608104
30 changed files with 3501 additions and 434 deletions
+19 -9
View File
@@ -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
include:
- 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
Generated
+425 -15
View File
@@ -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",
+5 -2
View File
@@ -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"
+20
View File
@@ -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"]`).
+17
View File
@@ -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"]
+6
View File
@@ -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"]
+24
View File
@@ -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"
+10
View File
@@ -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"
+75 -6
View File
@@ -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,10 +88,30 @@ pub fn build(
env_vars.push(("LDFLAGS", ldflags));
}
use crate::builder::state::{BuildStep, StateTracker};
let mut state = StateTracker::new(&actual_src)?;
// Run configure
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()
};
if !state.is_done(BuildStep::Configured) {
println!("Running configure...");
let mut configure_cmd = Command::new("./configure");
configure_cmd.current_dir(&actual_src);
let configure_path = if flags.build_dir.is_some() {
"../configure"
} else {
"./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);
@@ -110,23 +138,28 @@ pub fn build(
let status = configure_cmd
.status()
.with_context(|| format!("Failed to run configure in {}", actual_src.display()))?;
.with_context(|| format!("Failed to run configure in {}", build_dir.display()))?;
if !status.success() {
anyhow::bail!("configure failed with status: {}", status);
}
state.mark_done(BuildStep::Configured)?;
} else {
println!("Skipping configure (already done)");
}
if !state.is_done(BuildStep::PostCompileDone) {
// Run make
println!("Running make...");
let mut make_cmd = Command::new("make");
make_cmd.current_dir(&actual_src);
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 {}", actual_src.display()))?;
.with_context(|| format!("Failed to run make in {}", build_dir.display()))?;
if !status.success() {
anyhow::bail!("make failed with status: {}", status);
@@ -134,7 +167,12 @@ pub fn build(
// 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{}...",
@@ -146,7 +184,7 @@ pub fn build(
);
let mut install_cmd = fakeroot::wrap_install_command("make", destdir);
install_cmd.current_dir(&actual_src);
install_cmd.current_dir(&build_dir);
install_cmd.arg("install");
let mut install_env = env_vars.clone();
@@ -163,6 +201,10 @@ pub fn build(
// 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<String> {
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);
}
}
+68
View File
@@ -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(())
}
}
+53
View File
@@ -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,7 +99,11 @@ pub fn build(
None
};
use crate::builder::state::{BuildStep, StateTracker};
let mut state = StateTracker::new(&actual_src)?;
// Run cmake configure
if !state.is_done(BuildStep::Configured) {
println!("Running cmake configure...");
let mut cmake_cmd = Command::new("cmake");
cmake_cmd.current_dir(&build_dir);
@@ -118,7 +130,12 @@ pub fn build(
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");
@@ -134,6 +151,15 @@ pub fn build(
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{}...",
@@ -158,6 +184,12 @@ pub fn build(
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);
}
}
+125 -3
View File
@@ -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 <local-spec>` 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,6 +49,10 @@ pub fn build(
);
}
use crate::builder::state::{BuildStep, StateTracker};
let mut state = StateTracker::new(&src_dir)?;
if !state.is_done(BuildStep::PostInstallDone) {
println!(
"Running custom build script{}...",
if fakeroot::is_root() {
@@ -39,9 +62,24 @@ pub fn build(
}
);
let build_dir = if let Some(dir) = &flags.build_dir {
let bdir = src_dir.join(dir);
fs::create_dir_all(&bdir)?;
bdir
} else {
src_dir.to_path_buf()
};
let mut cmd = fakeroot::wrap_install_command("bash", destdir);
cmd.current_dir(src_dir);
cmd.arg(&build_script);
cmd.current_dir(&build_dir);
// 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);
if !flags.cflags.is_empty() {
env_vars.push(("CFLAGS", flags.cflags.join(" ")));
@@ -61,8 +99,9 @@ pub fn build(
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((
"NYAPM_SPECDIR",
"DEPOT_SPECDIR",
spec.spec_dir.to_string_lossy().into_owned(),
));
@@ -73,6 +112,10 @@ pub fn build(
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()));
@@ -98,6 +141,85 @@ pub fn build(
if !status.success() {
anyhow::bail!("Custom build script failed with status: {}", status);
}
state.mark_done(BuildStep::PostInstallDone)?;
} else {
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(())
}
}
+193
View File
@@ -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(())
}
}
+41
View File
@@ -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,7 +83,11 @@ pub fn build(
None
};
use crate::builder::state::{BuildStep, StateTracker};
let mut state = StateTracker::new(src_dir)?;
// Run meson setup
if !state.is_done(BuildStep::Configured) {
println!("Running meson setup...");
let mut meson_cmd = Command::new("meson");
meson_cmd.current_dir(src_dir);
@@ -95,7 +107,12 @@ pub fn build(
if !status.success() {
anyhow::bail!("meson setup failed");
}
state.mark_done(BuildStep::Configured)?;
} else {
println!("Skipping meson setup (already done)");
}
if !state.is_done(BuildStep::PostCompileDone) {
// Run ninja build
println!("Running ninja...");
let mut ninja_cmd = Command::new("ninja");
@@ -111,6 +128,13 @@ pub fn build(
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{}...",
@@ -136,6 +160,12 @@ pub fn build(
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);
}
}
+10 -1
View File
@@ -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]
+8
View File
@@ -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 ({})...",
+89
View File
@@ -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<BuildStep>,
}
pub struct StateTracker {
state_file: PathBuf,
state: State,
}
impl StateTracker {
pub fn new(source_dir: &Path) -> Result<Self> {
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(())
}
}
+92 -18
View File
@@ -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<String, Vec<toml::Value>>,
/// Mirrors mapping read from /etc/depot.d/mirrors.toml (reponame -> git url)
pub mirrors: std::collections::HashMap<String, String>,
/// Directory where git mirrors are cloned (absolute path). Defaults to
/// <rootfs>/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<PathBuf> = 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]
+174 -6
View File
@@ -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<String> = 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<Vec<String>> {
}
let conn = Connection::open(db_path)?;
let pkg_id: i64 = conn
.query_row(
// 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<i64> = conn.query_row(
"SELECT id FROM packages WHERE name = ?1",
params![name],
|row| row.get(0),
)
.context(format!("Package '{}' not found", name))?;
);
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<String> = 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 `<rootfs>/var/lib/depot/packages.db`, return
/// the `<rootfs>` portion. Otherwise return None.
fn detect_rootfs_from_db_path(db_path: &Path) -> Option<std::path::PathBuf> {
// 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();
+138
View File
@@ -208,6 +208,144 @@ impl RepoManager {
}
}
/// Synchronize git mirrors into /usr/src/depot/<reponame>
pub fn sync_mirrors(repo_dir: &std::path::Path, mirrors: &std::collections::HashMap<String, String>) -> Result<()> {
use git2::{Repository, FetchOptions, Cred, RemoteCallbacks, ResetType, build::RepoBuilder};
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<String, String>) -> 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())
+29 -17
View File
@@ -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<PathBuf>) -> 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/<pkg>/*.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(),
+91 -11
View File
@@ -79,6 +79,12 @@ enum Commands {
},
/// Show current configuration
Config,
/// Create a new package specification interactively
MakeSpec {
/// Output file path (defaults to <name>.toml)
#[arg(short, long)]
output: Option<PathBuf>,
},
}
#[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<tempfile::TempDir>) =
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,10 +398,14 @@ 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) {
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()?;
// 7. Check runtime dependencies (warn only)
@@ -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(())
+564
View File
@@ -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<String> {
// helper to hash a Read
fn hash_reader<R: Read>(r: &mut R) -> anyhow::Result<String> {
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<PackageSpec> {
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<String> {
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);
}
}
+2
View File
@@ -1,7 +1,9 @@
//! Package specification parsing
mod interactive;
mod packager;
mod spec;
pub use interactive::*;
pub use packager::Packager;
pub use spec::*;
+3 -2
View File
@@ -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(),
+140 -13
View File
@@ -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<PackageInfo>,
#[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<PackageInfo> {
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<String>,
/// Reserved for future package replacement feature
#[serde(default)]
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[allow(dead_code)]
pub replaces: Vec<String>,
}
/// 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<String>,
@@ -611,6 +691,12 @@ pub struct BuildFlags {
#[serde(default)]
pub post_install: Vec<String>,
/// Specific commands for 'makefile' build type
#[serde(default)]
pub makefile_commands: Vec<String>,
#[serde(default)]
pub makefile_install_commands: Vec<String>,
/// 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<String>,
/// 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<Vec<String>, 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<String>,
+309 -38
View File
@@ -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::<Vec<_>>();
if top.len() == 1 && top[0].path().is_dir() {
let top_name = top[0].file_name().to_string_lossy().to_string();
let expected_basename = dest
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
// 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::<Vec<_>>();
if top.len() == 1 && top[0].path().is_dir() {
let top_name = top[0].file_name().to_string_lossy().to_string();
let expected_basename = dest
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
// 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::<Vec<_>>();
if top.len() == 1 && top[0].path().is_dir() {
let top_name = top[0].file_name().to_string_lossy().to_string();
let expected_basename = dest
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
// 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::<Vec<_>>();
if top.len() == 1 && top[0].path().is_dir() {
let top_name = top[0].file_name().to_string_lossy().to_string();
let expected_basename = dest
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
// 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::<Vec<_>>();
if top.len() == 1 && top[0].path().is_dir() {
let top_name = top[0].file_name().to_string_lossy().to_string();
let expected_basename = dest
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
// 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::<Vec<_>>();
if top.len() == 1 && top[0].path().is_dir() {
let top_name = top[0].file_name().to_string_lossy().to_string();
let expected_basename = dest
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
// 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<usize> {
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::*;
+367 -24
View File
@@ -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,31 +226,19 @@ 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:<hex>` (or just `<hex>` — default)
/// - `sha512:<hex>`
/// - `md5:<hex>`
/// - `skip` to bypass verification
fn verify_checksum(path: &Path, expected: &str) -> Result<bool> {
// Skip verification if requested
if expected.to_lowercase() == "skip" {
println!("Checksum verification skipped");
return Ok(true);
// 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)
}
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())
}
/// Derive a stable filename from a URL.
///
@@ -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<Option<String>> {
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("<!doctype html") || head_str.starts_with("<html") || head_str.contains("<html") {
// If this came from SourceForge project URL, try to extract a direct
// downloads.sourceforge.net mirror link and return it for a retry.
let body = String::from_utf8_lossy(head).to_string();
if orig_url.contains("sourceforge.net") {
// helper: find nearest href attribute before `pos` and support
// both double- and single-quoted attributes.
let find_href_before = |body: &str, pos: usize| -> Option<String> {
if let Some(start) = body[..pos].rfind("href=\"") {
let rest = &body[start + 6..];
if let Some(end) = rest.find('"') {
return Some(rest[..end].to_string());
}
}
if let Some(start) = body[..pos].rfind("href='") {
let rest = &body[start + 6..];
if let Some(end) = rest.find('\'') {
return Some(rest[..end].to_string());
}
}
None
};
// look for downloads.sourceforge.net links in HTML
if let Some(pos) = body.find("downloads.sourceforge.net") {
if let Some(href) = find_href_before(&body, pos) {
let alt = if href.starts_with("//") {
format!("https:{}", href)
} else if href.starts_with("/") {
format!("https://downloads.sourceforge.net{}", href)
} else {
href
};
return Ok(Some(alt));
}
}
// 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 "!<arch>\n"
head.starts_with(b"!<arch>")
} else if lower.ends_with(".rpm") {
// rpm contains cpio magic later; best-effort: accept
true
} else {
// Unknown extension -> be permissive
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, "<!doctype html><html><body>No direct link</body></html>").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, "<!doctype html><a href='//downloads.sourceforge.net/project/zsh/zsh/5.9/zsh-5.9.tar.xz?download'>download</a>").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());
}
}
+18 -3
View File
@@ -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<()> {
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 {
+134 -14
View File
@@ -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<String> {
let mut file = fs::File::open(path)?;
/// 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<bool> {
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 buffer = [0u8; 8192];
let mut buf = [0u8; 8192];
loop {
let n = file.read(&mut buffer)?;
let n = f.read(&mut buf)?;
if n == 0 {
break;
}
hasher.update(&buffer[..n]);
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());
}
}