feat: add repo planning/binary repo support and Meson build integration

- add repos.toml source/binary repo configuration with arch overrides and priorities
- add dependency planner/execution plan flow with interactive provider selection and --yes support
- expand binary repo DB metadata (sha512, dependencies, file lists) and repo owns/search helpers
- add rootfs-scoped fd locking and minisign detached signing/verification helpers
- add Meson build/test support for the project with builddir-local CARGO_HOME/CARGO_TARGET_DIR
- install contrib example configs into sysconfdir (/etc/depot.d layout) via Meson
- improve builder env handling (sanitized PATH, tool env passthrough, CXXFLAGS support)
- support += appends in package specs and add build.flags.cxxflags parsing
- prompt for git credentials when private repo auth is required
- update docs/examples and dependency setup (system libs, zstdmt, tempfile pin)
This commit is contained in:
2026-02-24 21:38:12 -06:00
parent d63ad03e98
commit 9953d4b2ee
30 changed files with 4536 additions and 111 deletions
Generated
+206 -17
View File
@@ -421,6 +421,12 @@ dependencies = [
"typenum",
]
[[package]]
name = "ct-codecs"
version = "1.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b10589d1a5e400d61f9f38f12f884cfd080ff345de8f17efda36fe0e4a02aa8"
[[package]]
name = "deflate64"
version = "0.1.10"
@@ -435,14 +441,17 @@ dependencies = [
"ar",
"bzip2",
"clap",
"fd-lock",
"flate2",
"git2",
"indicatif",
"inquire",
"libz-sys",
"md5",
"minisign",
"nix",
"openssl-sys",
"petgraph",
"reqwest",
"rusqlite",
"semver",
@@ -584,6 +593,17 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "fd-lock"
version = "4.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78"
dependencies = [
"cfg-if",
"rustix",
"windows-sys 0.59.0",
]
[[package]]
name = "filetime"
version = "0.2.26"
@@ -602,6 +622,12 @@ version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "flate2"
version = "1.1.9"
@@ -631,6 +657,21 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -923,6 +964,22 @@ dependencies = [
"tower-service",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.19"
@@ -1117,9 +1174,9 @@ dependencies = [
[[package]]
name = "inquire"
version = "0.9.3"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "979f5ab9760427ada4fa5762b2d905e5b12704fb1fada07b6bfa66aeaa586f87"
checksum = "6654738b8024300cf062d04a1c13c10c8e2cea598ec1c47dc9b6641159429756"
dependencies = [
"bitflags",
"crossterm",
@@ -1271,7 +1328,6 @@ version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
@@ -1292,9 +1348,9 @@ dependencies = [
[[package]]
name = "libz-sys"
version = "1.1.23"
version = "1.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7"
checksum = "4735e9cbde5aac84a5ce588f6b23a90b9b0b528f6c5a8db8a4aff300463a0839"
dependencies = [
"cc",
"libc",
@@ -1380,6 +1436,18 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minisign"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6bf96cef396a17a96f7600281aa4da9229860b7a082601b1f6db6eaa5f99ee5"
dependencies = [
"ct-codecs",
"getrandom 0.3.4",
"rpassword",
"scrypt",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -1402,6 +1470,23 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "native-tls"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe 0.1.6",
"openssl-sys",
"schannel",
"security-framework 2.11.1",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "nix"
version = "0.31.1"
@@ -1441,6 +1526,32 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "openssl"
version = "0.10.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
dependencies = [
"bitflags",
"cfg-if",
"foreign-types",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "openssl-probe"
version = "0.1.6"
@@ -1453,15 +1564,6 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f50d9b3dabb09ecd771ad0aa242ca6894994c130308ca3d7684634df8037391"
[[package]]
name = "openssl-src"
version = "300.5.5+3.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f1787d533e03597a7934fd0a765f0d28e94ecc5fb7789f8053b1e699a56f709"
dependencies = [
"cc",
]
[[package]]
name = "openssl-sys"
version = "0.9.111"
@@ -1470,7 +1572,6 @@ checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
dependencies = [
"cc",
"libc",
"openssl-src",
"pkg-config",
"vcpkg",
]
@@ -1520,6 +1621,18 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "petgraph"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
dependencies = [
"fixedbitset",
"hashbrown 0.15.5",
"indexmap",
"serde",
]
[[package]]
name = "pin-project-lite"
version = "0.2.16"
@@ -1758,10 +1871,12 @@ dependencies = [
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
@@ -1770,6 +1885,7 @@ dependencies = [
"rustls-platform-verifier",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tower",
"tower-http",
@@ -1794,6 +1910,27 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rpassword"
version = "7.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66d4c8b64f049c6721ec8ccec37ddfc3d641c4a7fca57e8f2a89de509c73df39"
dependencies = [
"libc",
"rtoolbox",
"windows-sys 0.59.0",
]
[[package]]
name = "rtoolbox"
version = "0.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7cc970b249fbe527d6e02e0a227762c9108b2f49d81094fe357ffc6d14d7f6f"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]]
name = "rusqlite"
version = "0.38.0"
@@ -1860,7 +1997,7 @@ dependencies = [
"openssl-probe 0.2.0",
"rustls-pki-types",
"schannel",
"security-framework",
"security-framework 3.5.1",
]
[[package]]
@@ -1888,7 +2025,7 @@ dependencies = [
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework 3.5.1",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
@@ -1918,6 +2055,15 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "salsa20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213"
dependencies = [
"cipher",
]
[[package]]
name = "same-file"
version = "1.0.6"
@@ -1942,6 +2088,30 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "scrypt"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f"
dependencies = [
"pbkdf2",
"salsa20",
"sha2",
]
[[package]]
name = "security-framework"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags",
"core-foundation 0.9.4",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework"
version = "3.5.1"
@@ -2338,6 +2508,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
@@ -2837,6 +3017,15 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
+11 -16
View File
@@ -11,19 +11,19 @@ anyhow = "1.0.102"
ar = "0.9.0"
bzip2 = "0.6.1"
clap = { version = "4.5.60", features = ["derive"] }
flate2 = { version = "1.1.9", default-features = false, features = ["miniz_oxide"] }
flate2 = "1.1.9"
git2 = { version = "0.20.4", features = ["vendored-libgit2"] }
indicatif = "0.18.4"
nix = { version = "0.31.1", features = ["user"] }
reqwest = { version = "0.13.2", features = ["blocking", "rustls"] }
rusqlite = { version = "0.38.0", features = ["bundled"] }
openssl-sys = { version = "0.9", features = ["vendored"] }
reqwest = { version = "0.13.2", features = ["blocking", "native-tls"] }
rusqlite = "0.38.0"
openssl-sys = "0.9.111"
semver = "1.0.27"
serde = { version = "1.0.228", features = ["derive"] }
yaml-rust2 = "0.11.0"
sha2 = "0.10.9"
tar = "0.4.44"
tempfile = "3.25.0"
tempfile = "=3.25.0"
thiserror = "2.0.18"
toml = "0.9.8"
url = "2.5.8"
@@ -31,18 +31,13 @@ walkdir = "2.5.0"
xz2 = "0.1.7"
zip = "8.1.0"
zstd = { version = "0.13.3", features = ["zstdmt"] }
libz-sys = { version = "1.1.23", default-features = false, features = ["stock-zlib", "static"] }
inquire = "0.9.3"
libz-sys = "1.1.24"
inquire = "0.9.4"
md5 = "0.8.0"
suppaftp = "8.0.2"
minisign = "0.8.0"
petgraph = "0.8.3"
fd-lock = "4.0.4"
[dev-dependencies]
tempfile = "3.25.0"
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
panic = "abort"
strip = "symbols"
debug = false
tempfile = "=3.25.0"
+9 -1
View File
@@ -1,6 +1,6 @@
# depot
Depot is a source-based package manager for Linux, designed for Linux. It focuses on reproducibility, atomic installations, and ease of cross-compilation.
Depot is a source-based package manager designed for Linux. It focuses on reproducibility, atomic installations, and ease of cross-compilation.
## Features
@@ -36,10 +36,18 @@ depot install zlib-1.2.11-1-x86_64.depot.pkg.tar.zst
## Command Reference
- `install <SPEC_OR_ARCHIVE>`: Build and install a package.
- Resolves a full dependency plan first (binary repos and/or source specs), then executes in dependency order.
- Use `--yes` for non-interactive confirmation/provider selection.
- Use `--dry-run` to print the plan without performing work.
- `remove <PACKAGE>`: Remove an installed package.
- `build <SPEC>`: Build a package and create an archive without installing.
- Resolves and offers to install missing build/test dependencies before fetching/building.
- `info <PACKAGE_OR_SPEC>`: Show information about a package.
- `search <QUERY>`: Search enabled source/binary repos by package name and provided features.
- Use `--files` to search binary repo metadata file lists.
- `owns <PATH>`: Show which installed package owns a path.
- `list`: List all installed packages.
- `repo owns <PATH>`: Query binary repo metadata for path ownership.
- `repo create [DIR]`: Create a repository database from a directory of packages.
- `config`: Show current configuration and overrides.
+3 -1
View File
@@ -1,7 +1,7 @@
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.
for `/etc/depot.toml`, `/etc/depot.d/*.toml`, or user-level configs.
Recommended installation:
@@ -18,3 +18,5 @@ 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"]`).
- `contrib/depot.d/repos.toml` shows the new source/binary repo configuration
format used by `depot repo ...` commands.
+26
View File
@@ -0,0 +1,26 @@
[settings]
prefer_binary = true
[source.vertex]
url = "https://gitlab.com/vertex-linux/packages.git"
enabled = true
priority = 50
subdirs = ["core", "extra"]
[binary.core]
url = "https://repo.sfgos.net/core"
enabled = true
priority = 10
repo_db = "repo.db.zst"
[binary.core.arch.x86_64]
enabled = true
[binary.extra]
url = "https://repo.sfgos.net/extra"
enabled = true
priority = 20
repo_db = "repo.db.zst"
[binary.extra.arch.x86_64]
enabled = true
+127
View File
@@ -0,0 +1,127 @@
project(
'depot',
version: '0.1.0',
meson_version: '>=0.60.0',
)
fs = import('fs')
cargo = find_program('cargo', required: true)
find_prog = find_program('find', required: true)
sh = find_program('sh', required: true)
src_root = meson.project_source_root()
build_root = meson.project_build_root()
src_root_prefix = src_root + '/'
cargo_profile = 'debug'
cargo_release = false
if get_option('buildtype') != 'debug'
cargo_profile = 'release'
cargo_release = true
endif
cargo_inputs = files('Cargo.toml')
if fs.is_file('Cargo.lock')
cargo_inputs += files('Cargo.lock')
endif
cargo_src_list = run_command(
find_prog,
join_paths(src_root, 'src'),
'-type',
'f',
'(',
'-name',
'*.rs',
'-o',
'-name',
'*.toml',
')',
check: true,
capture: true,
).stdout().strip()
foreach rel : cargo_src_list.split('\n')
if rel == ''
continue
endif
if rel.startswith(src_root_prefix)
rel = rel.replace(src_root_prefix, '')
endif
cargo_inputs += files(rel)
endforeach
depot_bin = custom_target(
'depot-bin',
input: cargo_inputs,
output: 'depot',
command: [
sh,
join_paths(src_root, 'tools', 'meson', 'cargo-build.sh'),
cargo,
src_root,
build_root,
cargo_profile,
cargo_release ? '1' : '0',
'@OUTPUT@',
],
console: true,
build_by_default: true,
install: true,
install_dir: get_option('bindir'),
)
test(
'cargo-test',
sh,
args: [
join_paths(src_root, 'tools', 'meson', 'cargo-test.sh'),
cargo.full_path(),
src_root,
build_root,
],
suite: ['cargo'],
)
# System configuration examples installed under the actual runtime lookup paths.
install_data(
'contrib/depot.toml',
install_dir: get_option('sysconfdir'),
)
example_sysconf_dir = join_paths(get_option('sysconfdir'), 'depot.d')
example_depotd_list = run_command(
find_prog,
join_paths(src_root, 'contrib', 'depot.d'),
'-maxdepth',
'1',
'-type',
'f',
'-name',
'*.toml',
check: true,
capture: true,
).stdout().strip()
example_depotd_files = []
foreach rel : example_depotd_list.split('\n')
if rel == ''
continue
endif
if rel.startswith(src_root_prefix)
rel = rel.replace(src_root_prefix, '')
endif
example_depotd_files += files(rel)
endforeach
if example_depotd_files.length() > 0
install_data(example_depotd_files, install_dir: example_sysconf_dir)
endif
# Per-user example is documentation, not a system config file.
install_data(
'contrib/user.depot.toml.example',
install_dir: join_paths(get_option('datadir'), 'doc', meson.project_name(), 'examples'),
)
install_data(
'contrib/README.md',
install_dir: join_paths(get_option('datadir'), 'doc', meson.project_name()),
)
+4 -4
View File
@@ -60,7 +60,7 @@ pub fn build(
let mut configure_cmd = Command::new(&configure_path);
configure_cmd.current_dir(&build_dir);
crate::builder::prepare_command(&mut configure_cmd, &env_vars);
crate::builder::prepare_tool_command(&mut configure_cmd, &env_vars);
configure_cmd.arg(format!("--prefix={}", flags.prefix));
@@ -143,7 +143,7 @@ pub fn build(
make_cmd.arg(target);
}
crate::builder::prepare_command(&mut make_cmd, &env_vars);
crate::builder::prepare_tool_command(&mut make_cmd, &env_vars);
let status = make_cmd.status().with_context(|| {
format!("Failed to run {} in {}", make_exec, make_dir.display())
@@ -194,7 +194,7 @@ pub fn build(
for test_target in &test_targets {
test_cmd.arg(test_target);
}
crate::builder::prepare_command(&mut test_cmd, &env_vars);
crate::builder::prepare_tool_command(&mut test_cmd, &env_vars);
let test_targets_display = test_targets.join(" ");
let status = test_cmd.status().with_context(|| {
@@ -305,7 +305,7 @@ pub fn build(
"DESTDIR".to_string(),
destdir.to_string_lossy().into_owned(),
));
crate::builder::prepare_command(&mut install_cmd, &install_env);
crate::builder::prepare_tool_command(&mut install_cmd, &install_env);
let status = install_cmd.status().with_context(|| {
format!(
+4 -4
View File
@@ -82,7 +82,7 @@ pub fn build(
cmake_cmd.arg(&expanded);
}
crate::builder::prepare_command(&mut cmake_cmd, &env_vars);
crate::builder::prepare_tool_command(&mut cmake_cmd, &env_vars);
let status = cmake_cmd.status().context("Failed to run cmake")?;
if !status.success() {
@@ -109,7 +109,7 @@ pub fn build(
}
}
crate::builder::prepare_command(&mut build_cmd, &env_vars);
crate::builder::prepare_tool_command(&mut build_cmd, &env_vars);
let status = build_cmd
.status()
@@ -131,7 +131,7 @@ pub fn build(
for target in &test_targets {
test_cmd.arg(target);
}
crate::builder::prepare_command(&mut test_cmd, &env_vars);
crate::builder::prepare_tool_command(&mut test_cmd, &env_vars);
let status = test_cmd.status().with_context(|| {
format!(
@@ -182,7 +182,7 @@ pub fn build(
"DESTDIR".to_string(),
destdir.to_string_lossy().into_owned(),
));
crate::builder::prepare_command(&mut install_cmd, &install_env);
crate::builder::prepare_tool_command(&mut install_cmd, &install_env);
let status = install_cmd
.status()
+66 -7
View File
@@ -118,7 +118,7 @@ pub fn build(
};
cmd.current_dir(&build_dir);
crate::builder::prepare_command(&mut cmd, &env_vars);
crate::builder::prepare_tool_command(&mut cmd, &env_vars);
// Run the command and include the OS error on spawn failures for clearer diagnostics
let status = cmd.status().map_err(|e| {
@@ -176,9 +176,16 @@ fn build_function_mode_command(
) -> Result<Command> {
let mut wrapper = String::new();
wrapper.push_str("set -eu\n");
wrapper.push_str("depot_has_function() {\n");
wrapper.push_str(" case \"$(type \"$1\" 2>/dev/null || :)\" in\n");
wrapper.push_str(" *function*) return 0 ;;\n");
wrapper.push_str(" *) return 1 ;;\n");
wrapper.push_str(" esac\n");
wrapper.push_str("}\n");
wrapper.push_str("depot_build_ran=0\n");
wrapper.push_str(". \"$1\"\n");
wrapper.push_str("if command -v depot_build >/dev/null 2>&1; then depot_build;\n");
wrapper.push_str("elif command -v build >/dev/null 2>&1; then build;\n");
wrapper.push_str("if depot_has_function depot_build; then depot_build; depot_build_ran=1;\n");
wrapper.push_str("elif depot_has_function build; then build; depot_build_ran=1;\n");
wrapper.push_str("fi\n");
let primary = &spec.package.name;
@@ -195,18 +202,28 @@ fn build_function_mode_command(
wrapper.push_str(&format!(
"DEPOT_OUTPUT_NAME='{q_name}'; DEPOT_OUTPUT_DESTDIR='{q_dest}'; DESTDIR=\"$DEPOT_OUTPUT_DESTDIR\"; export DEPOT_OUTPUT_NAME DEPOT_OUTPUT_DESTDIR DESTDIR\n"
));
wrapper.push_str("depot_output_installed=0\n");
wrapper.push_str(&format!(
"if command -v depot_install_{fn_suffix} >/dev/null 2>&1; then depot_install_{fn_suffix};\n"
"if depot_has_function depot_install_{fn_suffix}; then depot_install_{fn_suffix}; depot_output_installed=1;\n"
));
wrapper.push_str(&format!(
"elif command -v install_{fn_suffix} >/dev/null 2>&1; then install_{fn_suffix};\n"
"elif depot_has_function install_{fn_suffix}; then install_{fn_suffix}; depot_output_installed=1;\n"
));
if out.name == *primary {
wrapper
.push_str("elif command -v depot_install >/dev/null 2>&1; then depot_install;\n");
wrapper.push_str("elif command -v install >/dev/null 2>&1; then install;\n");
.push_str("elif depot_has_function depot_install; then depot_install; depot_output_installed=1;\n");
wrapper.push_str(
"elif depot_has_function install; then install; depot_output_installed=1;\n",
);
wrapper.push_str("elif [ \"$depot_build_ran\" = 1 ]; then depot_output_installed=1;\n");
}
wrapper.push_str("fi\n");
wrapper.push_str("if [ \"$depot_output_installed\" != 1 ]; then\n");
wrapper.push_str(
" echo \"depot: missing install handler for output '$DEPOT_OUTPUT_NAME' in custom function mode\" >&2\n",
);
wrapper.push_str(" exit 1\n");
wrapper.push_str("fi\n");
}
let mut cmd = fakeroot::wrap_install_command("sh", destdir);
@@ -363,4 +380,46 @@ depot_install_dev_pkg() {
);
Ok(())
}
#[test]
fn test_build_function_mode_errors_when_output_handler_missing() -> Result<()> {
let tmp_src = tempdir()?;
let tmp_dest = tempdir()?;
let build_sh = tmp_src.path().join("build.sh");
std::fs::write(
&build_sh,
r#"#!/bin/sh
depot_build() {
:
}
depot_install() {
mkdir -p "$DESTDIR/usr/share"
echo primary > "$DESTDIR/usr/share/primary.txt"
}
"#,
)?;
#[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("demo", "1.0");
spec.packages.push(crate::package::PackageInfo {
name: "dev-pkg".into(),
version: "1.0".into(),
revision: 1,
description: "d".into(),
homepage: "h".into(),
license: vec!["MIT".into()],
});
let err = build(&spec, tmp_src.path(), tmp_dest.path(), None, true)
.expect_err("missing per-output install handler should fail");
assert!(err.to_string().contains("Custom build script failed"));
Ok(())
}
}
+2 -2
View File
@@ -43,7 +43,7 @@ pub fn build(
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);
crate::builder::prepare_tool_command(&mut cmd, &env_vars);
let status = cmd
.status()
@@ -84,7 +84,7 @@ pub fn build(
"DESTDIR".to_string(),
destdir.to_string_lossy().into_owned(),
));
crate::builder::prepare_command(&mut cmd, &install_env);
crate::builder::prepare_tool_command(&mut cmd, &install_env);
let status = cmd
.status()
+3 -3
View File
@@ -51,7 +51,7 @@ pub fn build(
meson_cmd.arg(arg);
}
crate::builder::prepare_command(&mut meson_cmd, &env_vars);
crate::builder::prepare_tool_command(&mut meson_cmd, &env_vars);
let status = meson_cmd.status().context("Failed to run meson setup")?;
if !status.success() {
@@ -71,7 +71,7 @@ pub fn build(
ninja_cmd.current_dir(&build_dir);
ninja_cmd.arg("-j").arg(num_cpus().to_string());
crate::builder::prepare_command(&mut ninja_cmd, &env_vars);
crate::builder::prepare_tool_command(&mut ninja_cmd, &env_vars);
let status = ninja_cmd
.status()
@@ -106,7 +106,7 @@ pub fn build(
"DESTDIR".to_string(),
destdir.to_string_lossy().into_owned(),
));
crate::builder::prepare_command(&mut install_cmd, &install_env);
crate::builder::prepare_tool_command(&mut install_cmd, &install_env);
let status = install_cmd
.status()
+103 -3
View File
@@ -13,8 +13,9 @@ pub mod state;
use crate::cross::CrossConfig;
use crate::package::{BuildType, PackageSpec};
use anyhow::Result;
use std::ffi::OsString;
use std::path::Path;
use std::process::Command;
use std::process::{Command, Stdio};
pub type EnvVars = Vec<(String, String)>;
@@ -41,6 +42,9 @@ pub fn standard_build_env(
if !flags.cflags.is_empty() {
set_env_var(&mut env_vars, "CFLAGS", flags.cflags.join(" "));
}
if !flags.cxxflags.is_empty() {
set_env_var(&mut env_vars, "CXXFLAGS", flags.cxxflags.join(" "));
}
let ldflags = if !flags.ldflags.is_empty() || !flags.libc.is_empty() {
if flags.libc.is_empty() {
@@ -123,8 +127,29 @@ pub fn standard_build_env(
/// Prepare a Command with a hermetic environment and some essential variables preserved.
pub fn prepare_command(cmd: &mut Command, env_vars: &EnvVars) {
cmd.env_clear();
if let Some(path) = sanitized_build_path() {
cmd.env("PATH", path);
}
// Preserve essential environment variables
for var in &["PATH", "LANG", "HOME", "DESTDIR", "DEPOT_ROOTFS"] {
for var in &[
"LANG",
"HOME",
"DESTDIR",
"DEPOT_ROOTFS",
"CARGO_HOME",
"RUSTUP_HOME",
"RUSTUP_TOOLCHAIN",
"RUSTC",
"RUSTDOC",
"TERM",
"COLORTERM",
"NO_COLOR",
"CLICOLOR",
"CLICOLOR_FORCE",
"FORCE_COLOR",
] {
if let Ok(val) = std::env::var(var) {
cmd.env(var, val);
}
@@ -139,6 +164,35 @@ pub fn prepare_command(cmd: &mut Command, env_vars: &EnvVars) {
}
}
fn sanitized_build_path() -> Option<OsString> {
use std::path::PathBuf;
let mut parts: Vec<PathBuf> = std::env::var_os("PATH")
.map(|raw| std::env::split_paths(&raw).collect())
.unwrap_or_default();
for dir in ["/bin", "/usr/bin", "/sbin", "/usr/sbin"] {
let path = PathBuf::from(dir);
if path.exists() && !parts.iter().any(|p| p == &path) {
parts.push(path);
}
}
if parts.is_empty() {
return None;
}
std::env::join_paths(parts).ok()
}
/// Prepare a Command for interactive tool execution with live terminal output.
pub fn prepare_tool_command(cmd: &mut Command, env_vars: &EnvVars) {
prepare_command(cmd, env_vars);
cmd.stdin(Stdio::inherit());
cmd.stdout(Stdio::inherit());
cmd.stderr(Stdio::inherit());
}
/// Build a package using the appropriate build system
pub fn build(
spec: &PackageSpec,
@@ -230,6 +284,8 @@ mod tests {
std::env::set_var("HOME", "/home/test");
std::env::set_var("SHELL", "/bin/zsh");
std::env::set_var("DEPOT_ROOTFS", "/my/rootfs");
std::env::set_var("TERM", "xterm-256color");
std::env::set_var("CLICOLOR_FORCE", "1");
}
prepare_command(&mut cmd, &vec![("MYVAR".to_string(), "myval".to_string())]);
@@ -251,6 +307,16 @@ mod tests {
envs.get(OsStr::new("DEPOT_ROOTFS")),
Some(&Some(std::ffi::OsString::from("/my/rootfs").as_os_str()))
);
assert_eq!(
envs.get(OsStr::new("TERM")),
Some(&Some(
std::ffi::OsString::from("xterm-256color").as_os_str()
))
);
assert_eq!(
envs.get(OsStr::new("CLICOLOR_FORCE")),
Some(&Some(std::ffi::OsString::from("1").as_os_str()))
);
}
#[test]
@@ -267,15 +333,45 @@ mod tests {
);
}
#[test]
fn test_prepare_command_preserves_rust_toolchain_homes() {
let mut cmd = std::process::Command::new("ls");
unsafe {
std::env::set_var("CARGO_HOME", "/var/cache/cargo-home");
std::env::set_var("RUSTUP_HOME", "/var/cache/rustup-home");
}
prepare_command(&mut cmd, &Vec::new());
let envs: HashMap<_, _> = cmd.get_envs().collect();
assert_eq!(
envs.get(OsStr::new("CARGO_HOME")),
Some(&Some(
std::ffi::OsString::from("/var/cache/cargo-home").as_os_str()
))
);
assert_eq!(
envs.get(OsStr::new("RUSTUP_HOME")),
Some(&Some(
std::ffi::OsString::from("/var/cache/rustup-home").as_os_str()
))
);
}
#[test]
fn test_standard_build_env_respects_export_compiler_flags_toggle() {
let spec = mk_spec(vec!["-O2"], vec!["-Wl,--as-needed"]);
let mut spec = mk_spec(vec!["-O2"], vec!["-Wl,--as-needed"]);
spec.build.flags.cxxflags = vec!["-O2".into(), "-fno-exceptions".into()];
let enabled = standard_build_env(&spec, None, true, true);
assert!(
enabled.iter().any(|(k, v)| k == "CFLAGS" && v == "-O2"),
"expected CFLAGS to be exported when enabled"
);
assert!(
enabled
.iter()
.any(|(k, v)| k == "CXXFLAGS" && v == "-O2 -fno-exceptions"),
"expected CXXFLAGS to be exported when enabled"
);
assert!(
enabled
.iter()
@@ -304,6 +400,10 @@ mod tests {
!disabled_env.iter().any(|(k, _)| k == "CFLAGS"),
"expected CFLAGS to be omitted when no_flags is set in spec"
);
assert!(
!disabled_env.iter().any(|(k, _)| k == "CXXFLAGS"),
"expected CXXFLAGS to be omitted when no_flags is set in spec"
);
assert!(
!disabled_env.iter().any(|(k, _)| k == "LDFLAGS"),
"expected LDFLAGS to be omitted when no_flags is set in spec"
+1 -1
View File
@@ -245,7 +245,7 @@ fn build_wheel_setup_py(
.arg("bdist_wheel")
.arg("--dist-dir")
.arg(dist_dir);
crate::builder::prepare_command(&mut cmd, &env_vars.to_vec());
crate::builder::prepare_tool_command(&mut cmd, &env_vars.to_vec());
let status = cmd
.status()
+1 -1
View File
@@ -99,7 +99,7 @@ pub fn build(
}
// Set environment
crate::builder::prepare_command(&mut cargo_cmd, &env_vars);
crate::builder::prepare_tool_command(&mut cargo_cmd, &env_vars);
let status = cargo_cmd
.status()
+331 -14
View File
@@ -1,10 +1,203 @@
//! Global configuration for Depot
use anyhow::{Context, Result};
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
use std::fs;
use std::path::{Path, PathBuf};
fn default_true() -> bool {
true
}
fn default_repo_db_filename() -> String {
"repo.db.zst".to_string()
}
fn resolve_rootfs_base(rootfs: &Path) -> PathBuf {
if rootfs.exists() {
rootfs.canonicalize().unwrap_or_else(|_| {
std::env::current_dir()
.map(|cwd| cwd.join(rootfs))
.unwrap_or_else(|_| rootfs.to_path_buf())
})
} else {
std::env::current_dir()
.map(|cwd| cwd.join(rootfs))
.unwrap_or_else(|_| rootfs.to_path_buf())
}
}
/// Global repo behavior settings loaded from `repos.toml`.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RepoSettings {
/// Prefer binary repos over source repos when both can satisfy a request.
#[serde(default)]
pub prefer_binary: bool,
}
impl Default for RepoSettings {
fn default() -> Self {
Self {
prefer_binary: false,
}
}
}
/// Source repository configuration entry loaded from `repos.toml`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourceRepo {
/// Git URL for the package repo.
pub url: String,
/// Whether the repo is enabled for lookups/sync.
#[serde(default = "default_true")]
pub enabled: bool,
/// Lower numbers are higher priority.
#[serde(default)]
pub priority: i32,
/// Optional subdirectories to scan/index inside the git checkout.
#[serde(default)]
pub subdirs: Vec<String>,
}
/// Binary repository configuration entry loaded from `repos.toml`.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct BinaryRepoArchEntry {
/// Whether this architecture is enabled for this repo.
#[serde(default = "default_true")]
pub enabled: bool,
/// Optional URL override for this architecture.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
/// Optional repo DB path override for this architecture.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repo_db: Option<String>,
}
/// Binary repository configuration entry loaded from `repos.toml`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BinaryRepo {
/// Base URL for the binary repository.
pub url: String,
/// Whether the repo is enabled for lookups.
#[serde(default = "default_true")]
pub enabled: bool,
/// Lower numbers are higher priority.
#[serde(default)]
pub priority: i32,
/// Repo database filename/path relative to `url`.
#[serde(default = "default_repo_db_filename")]
pub repo_db: String,
/// Architecture-specific overrides/enablement.
///
/// Example:
/// `[binary.core.arch.x86_64]`
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub arch: BTreeMap<String, BinaryRepoArchEntry>,
/// Allow unsigned repo metadata for this repo.
#[serde(default)]
pub allow_unsigned: bool,
}
impl Default for BinaryRepo {
fn default() -> Self {
Self {
url: String::new(),
enabled: true,
priority: 0,
repo_db: default_repo_db_filename(),
arch: BTreeMap::new(),
allow_unsigned: false,
}
}
}
impl BinaryRepo {
/// Return true if this repo is enabled for the requested machine architecture.
pub fn supports_arch(&self, machine_arch: &str) -> bool {
if self.arch.is_empty() {
return true;
}
self.arch
.get(machine_arch)
.map(|entry| entry.enabled)
.unwrap_or(false)
}
/// Return the effective base URL for `machine_arch`, including arch overrides.
pub fn effective_url_for_arch<'a>(&'a self, machine_arch: &str) -> Option<&'a str> {
if self.arch.is_empty() {
return Some(self.url.as_str());
}
let entry = self.arch.get(machine_arch)?;
if !entry.enabled {
return None;
}
Some(entry.url.as_deref().unwrap_or(self.url.as_str()))
}
/// Return the effective repo DB path for `machine_arch`, including arch overrides.
pub fn effective_repo_db_for_arch<'a>(&'a self, machine_arch: &str) -> Option<&'a str> {
if self.arch.is_empty() {
return Some(self.repo_db.as_str());
}
let entry = self.arch.get(machine_arch)?;
if !entry.enabled {
return None;
}
Some(entry.repo_db.as_deref().unwrap_or(self.repo_db.as_str()))
}
}
/// Parsed contents of `/etc/depot.d/repos.toml`.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct RepoConfigFile {
/// Global repo behavior settings.
#[serde(default, skip_serializing_if = "repo_settings_is_default")]
pub settings: RepoSettings,
/// Source repos keyed by repo name.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub source: BTreeMap<String, SourceRepo>,
/// Binary repos keyed by repo name.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub binary: BTreeMap<String, BinaryRepo>,
}
fn repo_settings_is_default(settings: &RepoSettings) -> bool {
!settings.prefer_binary
}
/// Return the canonical `repos.toml` path for the given rootfs.
pub fn repos_toml_path(rootfs: &Path) -> PathBuf {
resolve_rootfs_base(rootfs).join("etc/depot.d/repos.toml")
}
/// Load `repos.toml` for the given rootfs. Missing file returns defaults.
pub fn load_repos_config_file(rootfs: &Path) -> Result<RepoConfigFile> {
let path = repos_toml_path(rootfs);
if !path.exists() {
return Ok(RepoConfigFile::default());
}
let content =
fs::read_to_string(&path).with_context(|| format!("Failed to read {}", path.display()))?;
let parsed: RepoConfigFile =
toml::from_str(&content).with_context(|| format!("Failed to parse {}", path.display()))?;
Ok(parsed)
}
/// Save `repos.toml` for the given rootfs, creating `/etc/depot.d` as needed.
pub fn save_repos_config_file(rootfs: &Path, repos: &RepoConfigFile) -> Result<PathBuf> {
let path = repos_toml_path(rootfs);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create {}", parent.display()))?;
}
let content = toml::to_string_pretty(repos).context("Failed to serialize repos.toml")?;
fs::write(&path, content).with_context(|| format!("Failed to write {}", path.display()))?;
Ok(path)
}
/// Global configuration settings
#[derive(Clone)]
pub struct Config {
@@ -20,43 +213,43 @@ 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>>,
/// Parsed repo settings from `/etc/depot.d/repos.toml`.
pub repo_settings: RepoSettings,
/// Source repos from `/etc/depot.d/repos.toml` (and legacy mirrors fallback).
pub source_repos: BTreeMap<String, SourceRepo>,
/// Binary repos from `/etc/depot.d/repos.toml`.
pub binary_repos: BTreeMap<String, BinaryRepo>,
/// 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,
/// Cache directory for binary packages and repo metadata.
pub package_cache_dir: PathBuf,
}
impl Config {
/// Create config with paths relative to the given rootfs
pub fn for_rootfs(rootfs: &Path) -> Self {
let abs_rootfs = if rootfs.exists() {
rootfs.canonicalize().unwrap_or_else(|_| {
std::env::current_dir()
.map(|cwd| cwd.join(rootfs))
.unwrap_or_else(|_| rootfs.to_path_buf())
})
} else {
std::env::current_dir()
.map(|cwd| cwd.join(rootfs))
.unwrap_or_else(|_| rootfs.to_path_buf())
};
let abs_rootfs = resolve_rootfs_base(rootfs);
let is_system_root = abs_rootfs == Path::new("/") || abs_rootfs.as_os_str() == "/";
let is_root = crate::fakeroot::is_root();
let (cache_dir, build_dir, db_dir) = if is_system_root && !is_root {
let (cache_dir, package_cache_dir, build_dir, db_dir) = if is_system_root && !is_root {
let home = std::env::var("HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("/tmp"));
(
home.join(".cache/depot/sources"),
home.join(".cache/depot/packages"),
home.join(".cache/depot/build"),
home.join(".local/share/depot"),
)
} else {
(
abs_rootfs.join("var/cache/depot/sources"),
abs_rootfs.join("var/cache/depot/packages"),
abs_rootfs.join("var/cache/depot/build"),
abs_rootfs.join("var/lib/depot"),
)
@@ -69,8 +262,12 @@ impl Config {
build_overrides: toml::Value::Table(toml::map::Map::new()),
package_overrides: toml::Value::Table(toml::map::Map::new()),
appends: HashMap::new(),
repo_settings: RepoSettings::default(),
source_repos: BTreeMap::new(),
binary_repos: BTreeMap::new(),
mirrors: std::collections::HashMap::new(),
repo_clone_dir: abs_rootfs.join("usr/src/depot"),
package_cache_dir,
};
if let Err(e) = config.load_system(&abs_rootfs) {
@@ -185,7 +382,27 @@ impl Config {
}
}
// Load mirrors file: /etc/depot.d/mirrors.toml
// Load new repos file: /etc/depot.d/repos.toml (host fallback then rootfs override).
let host_repos_path = PathBuf::from("/etc/depot.d/repos.toml");
let repos_path = rootfs.join("etc/depot.d/repos.toml");
for path in [host_repos_path, repos_path] {
if !path.exists() {
continue;
}
let content = fs::read_to_string(&path)
.with_context(|| format!("Failed to read repos config: {}", path.display()))?;
let parsed: RepoConfigFile = toml::from_str(&content)
.with_context(|| format!("Failed to parse repos config: {}", path.display()))?;
self.repo_settings = parsed.settings;
for (name, repo) in parsed.source {
self.source_repos.insert(name, repo);
}
for (name, repo) in parsed.binary {
self.binary_repos.insert(name, repo);
}
}
// Load legacy 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(|| {
@@ -196,6 +413,12 @@ impl Config {
for (k, v) in table {
if let Some(s) = v.as_str() {
self.mirrors.insert(k.clone(), s.to_string());
self.source_repos.entry(k.clone()).or_insert(SourceRepo {
url: s.to_string(),
enabled: true,
priority: 0,
subdirs: Vec::new(),
});
}
}
}
@@ -213,6 +436,17 @@ impl Config {
Ok(())
}
/// Return enabled source repos as `name -> git URL` pairs for legacy sync/status code.
pub fn enabled_source_mirror_map(&self) -> std::collections::HashMap<String, String> {
let mut out = std::collections::HashMap::new();
for (name, repo) in &self.source_repos {
if repo.enabled {
out.insert(name.clone(), repo.url.clone());
}
}
out
}
}
fn merge_toml_values(base: &mut toml::Value, over: &toml::Value) {
@@ -460,4 +694,87 @@ cflags = ["-O3"]
Some(1)
);
}
#[test]
fn test_load_repos_toml() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let etc = root.join("etc/depot.d");
fs::create_dir_all(&etc).unwrap();
fs::write(
etc.join("repos.toml"),
r#"
[settings]
prefer_binary = true
[source.vertex]
url = "https://gitlab.com/vertex-linux/packages.git"
enabled = true
priority = 10
subdirs = ["core", "extra"]
[binary.vertex]
url = "https://repo.example.invalid"
enabled = false
priority = 5
repo_db = "repo.db.zst"
[binary.vertex.arch.x86_64]
enabled = true
"#,
)
.unwrap();
let config = Config::for_rootfs(root);
assert!(config.repo_settings.prefer_binary);
let src = config.source_repos.get("vertex").unwrap();
assert_eq!(src.url, "https://gitlab.com/vertex-linux/packages.git");
assert_eq!(src.subdirs, vec!["core".to_string(), "extra".to_string()]);
let bin = config.binary_repos.get("vertex").unwrap();
assert_eq!(bin.url, "https://repo.example.invalid");
assert!(!bin.enabled);
assert!(bin.arch.contains_key("x86_64"));
assert!(bin.supports_arch("x86_64"));
assert!(!bin.supports_arch("aarch64"));
}
#[test]
fn test_save_and_load_repos_config_file_roundtrip() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let mut repos = RepoConfigFile::default();
repos.source.insert(
"vertex".to_string(),
SourceRepo {
url: "https://gitlab.com/vertex-linux/packages.git".to_string(),
enabled: true,
priority: 1,
subdirs: vec!["core".to_string()],
},
);
repos.binary.insert(
"vertex".to_string(),
BinaryRepo {
url: "https://repo.example.invalid".to_string(),
enabled: true,
priority: 2,
repo_db: "repo.db.zst".to_string(),
arch: {
let mut map = BTreeMap::new();
map.insert("x86_64".to_string(), BinaryRepoArchEntry::default());
map
},
allow_unsigned: false,
},
);
let path = save_repos_config_file(root, &repos).unwrap();
assert!(path.exists());
let loaded = load_repos_config_file(root).unwrap();
assert!(loaded.source.contains_key("vertex"));
assert!(loaded.binary.contains_key("vertex"));
}
}
+30
View File
@@ -751,3 +751,33 @@ pub fn get_package_version(db_path: &Path, name: &str) -> Result<Option<String>>
.ok();
Ok(version)
}
/// Find the installed package that owns a filesystem path from the local DB.
pub fn owns_path(db_path: &Path, path: &Path) -> Result<Option<String>> {
if !db_path.exists() {
return Ok(None);
}
let normalized = path
.to_string_lossy()
.trim_start_matches('/')
.trim_start_matches("./")
.to_string();
if normalized.is_empty() {
return Ok(None);
}
let conn = Connection::open(db_path)?;
let owner = conn
.query_row(
"SELECT p.name
FROM files f
JOIN packages p ON p.id = f.package_id
WHERE f.path = ?1
LIMIT 1",
params![normalized],
|row| row.get(0),
)
.ok();
Ok(owner)
}
+875 -15
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -78,6 +78,11 @@ fn parse_dep(dep: &str) -> ParsedDep<'_> {
}
}
/// Return the dependency name portion without any version/operator suffix.
pub fn dep_name(dep: &str) -> &str {
parse_dep(dep).name
}
/// Compare two version strings using semver if possible, fallback to string compare
fn compare_versions(installed: &str, required: &str, op: VersionOp) -> bool {
// Try semver comparison first
@@ -134,6 +139,17 @@ fn is_dep_satisfied(
}
}
/// Check whether a dependency expression is satisfied by the installed package DB.
pub fn is_dep_satisfied_in_db(dep: &str, db_path: &Path) -> Result<bool> {
if !db_path.exists() {
return Ok(false);
}
let installed = db::get_installed_packages(db_path)?;
let provides = db::get_all_provides(db_path)?;
is_dep_satisfied(dep, &installed, &provides, db_path)
}
/// Check if all build dependencies are satisfied
pub fn check_build_deps(spec: &PackageSpec, db_path: &Path) -> Result<Vec<String>> {
let mut missing = Vec::new();
+44 -1
View File
@@ -16,11 +16,18 @@ pub struct PackageIndex {
by_provides: HashMap<String, Vec<PathBuf>>,
}
/// Source package search result from `PackageIndex`.
#[derive(Debug, Clone)]
pub struct SourceSearchHit {
pub name: String,
pub path: PathBuf,
pub provides: Vec<String>,
}
impl PackageIndex {
/// 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.
@@ -116,4 +123,40 @@ impl PackageIndex {
None
}
/// Return all source specs that provide the requested feature/package name.
pub fn find_providers(&self, name: &str) -> Vec<PathBuf> {
self.by_provides.get(name).cloned().unwrap_or_default()
}
/// Search indexed specs by package name or provided feature.
pub fn search(&self, query: &str) -> Vec<SourceSearchHit> {
let q = query.to_ascii_lowercase();
let mut provides_by_path: HashMap<PathBuf, Vec<String>> = HashMap::new();
for (provided, paths) in &self.by_provides {
for path in paths {
provides_by_path
.entry(path.clone())
.or_default()
.push(provided.clone());
}
}
let mut hits = Vec::new();
for (name, path) in &self.by_name {
let provides = provides_by_path.remove(path).unwrap_or_default();
let name_match = name.to_ascii_lowercase().contains(&q);
let provides_match = provides.iter().any(|p| p.to_ascii_lowercase().contains(&q));
if name_match || provides_match {
hits.push(SourceSearchHit {
name: name.clone(),
path: path.clone(),
provides,
});
}
}
hits.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.path.cmp(&b.path)));
hits
}
}
+76
View File
@@ -0,0 +1,76 @@
//! Rootfs-scoped advisory locking helpers.
use crate::config::Config;
use anyhow::{Context, Result};
use fd_lock::{RwLock, RwLockReadGuard, RwLockWriteGuard};
use std::fs::{self, File, OpenOptions};
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
/// Return the rootfs-scoped lock file path.
pub(crate) fn lock_path(config: &Config) -> PathBuf {
config.db_dir.join("lock")
}
/// Open the rootfs-scoped lock file as an fd-lock reader/writer.
pub(crate) fn open_lock(config: &Config) -> Result<RwLock<File>> {
let path = lock_path(config);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create lock dir {}", parent.display()))?;
}
let file = OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(&path)
.with_context(|| format!("Failed to open lock file {}", path.display()))?;
Ok(RwLock::new(file))
}
/// Acquire a shared/read lock without blocking.
pub(crate) fn try_read<'a>(
lock: &'a RwLock<File>,
path: &Path,
command_name: &str,
) -> Result<RwLockReadGuard<'a, File>> {
lock.try_read().map_err(|e| {
if e.kind() == ErrorKind::WouldBlock {
anyhow::anyhow!(
"Depot is busy (lock held by another process). Command '{}' needs a shared lock on {}",
command_name,
path.display()
)
} else {
anyhow::anyhow!(e).context(format!(
"Failed to acquire shared lock for '{}' on {}",
command_name,
path.display()
))
}
})
}
/// Acquire an exclusive/write lock without blocking.
pub(crate) fn try_write<'a>(
lock: &'a mut RwLock<File>,
path: &Path,
command_name: &str,
) -> Result<RwLockWriteGuard<'a, File>> {
lock.try_write().map_err(|e| {
if e.kind() == ErrorKind::WouldBlock {
anyhow::anyhow!(
"Depot is busy (lock held by another process). Command '{}' needs an exclusive lock on {}",
command_name,
path.display()
)
} else {
anyhow::anyhow!(e).context(format!(
"Failed to acquire exclusive lock for '{}' on {}",
command_name,
path.display()
))
}
})
}
+1102 -10
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -651,6 +651,19 @@ pub fn spec_to_minimal_toml(spec: &PackageSpec) -> anyhow::Result<String> {
),
);
}
if !spec.build.flags.cxxflags.is_empty() {
flags_tbl.insert(
"cxxflags".into(),
Value::Array(
spec.build
.flags
.cxxflags
.iter()
.map(|s| Value::String(s.clone()))
.collect(),
),
);
}
if !spec.build.flags.ldflags.is_empty() {
flags_tbl.insert(
"ldflags".into(),
@@ -1213,6 +1226,7 @@ mod tests {
flags.makefile_install_commands = vec!["make DESTDIR=$DESTDIR install".into()];
flags.cargs = vec!["--locked".into()];
flags.rustflags = vec!["-Ctarget-cpu=native".into()];
flags.cxxflags = vec!["-O2".into(), "-fno-rtti".into()];
flags.target = "x86_64-unknown-linux-gnu".into();
flags.keep = vec!["etc/locale.gen".into()];
flags.no_flags = true;
@@ -1266,6 +1280,7 @@ mod tests {
assert!(toml.contains("makefile_install_commands = ["));
assert!(toml.contains("cargs = ["));
assert!(toml.contains("rustflags = ["));
assert!(toml.contains("cxxflags = ["));
assert!(toml.contains("target = \"x86_64-unknown-linux-gnu\""));
assert!(toml.contains("keep = ["));
assert!(toml.contains("\"etc/locale.gen\""));
+185 -1
View File
@@ -58,12 +58,17 @@ impl PackageSpec {
let content = fs::read_to_string(&abs_path)
.with_context(|| format!("Failed to read package spec: {}", abs_path.display()))?;
let mut spec: PackageSpec = toml::from_str(&content)
let (base_content, appends) =
preprocess_spec_toml_appends(&content).with_context(|| {
format!("Failed to preprocess package spec: {}", abs_path.display())
})?;
let mut spec: PackageSpec = toml::from_str(&base_content)
.with_context(|| format!("Failed to parse package spec: {}", abs_path.display()))?;
spec.spec_dir = abs_path
.parent()
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
spec.apply_spec_appends(&appends)?;
// Require at least one source (remote or manual)
if spec.source.is_empty() && spec.manual_sources.is_empty() {
@@ -74,6 +79,28 @@ impl PackageSpec {
Ok(spec)
}
fn apply_spec_appends(
&mut self,
appends: &std::collections::HashMap<String, Vec<toml::Value>>,
) -> Result<()> {
for (key, values) in appends {
if let Some(subkey) = key.strip_prefix("build.flags.") {
self.apply_append(subkey, values);
continue;
}
if let Some(subkey) = key.strip_prefix("flags.") {
self.apply_append(subkey, values);
continue;
}
if !key.contains('.') {
self.apply_append(key, values);
continue;
}
anyhow::bail!("Unsupported '+=' key in package spec: {}", key);
}
Ok(())
}
fn validate_manual_sources(&self) -> Result<()> {
for (idx, manual) in self.manual_sources.iter().enumerate() {
let has_file = manual
@@ -206,6 +233,17 @@ impl PackageSpec {
self.build.flags.cflags = vec![s.to_string()];
}
}
"cxxflags" => {
if let Some(arr) = v.as_array() {
self.build.flags.cxxflags = arr
.iter()
.filter_map(|x| x.as_str())
.map(String::from)
.collect();
} else if let Some(s) = v.as_str() {
self.build.flags.cxxflags = vec![s.to_string()];
}
}
"ldflags" => {
if let Some(arr) = v.as_array() {
self.build.flags.ldflags = arr
@@ -485,6 +523,18 @@ impl PackageSpec {
}
}
}
"cxxflags" => {
for v in values {
if let Some(arr) = v.as_array() {
self.build
.flags
.cxxflags
.extend(arr.iter().filter_map(|x| x.as_str()).map(String::from));
} else if let Some(s) = v.as_str() {
self.build.flags.cxxflags.push(s.to_string());
}
}
}
"ldflags" => {
for v in values {
if let Some(arr) = v.as_array() {
@@ -815,6 +865,74 @@ impl PackageSpec {
}
}
fn preprocess_spec_toml_appends(
input: &str,
) -> Result<(String, std::collections::HashMap<String, Vec<toml::Value>>)> {
let mut base_text = String::new();
let mut appends = std::collections::HashMap::new();
let mut current_table: Option<String> = None;
let mut in_array_table = false;
for line in input.lines() {
let trimmed = line.trim();
if trimmed.starts_with("[[") && trimmed.ends_with("]]") && trimmed.len() >= 4 {
current_table = Some(trimmed[2..trimmed.len() - 2].trim().to_string());
in_array_table = true;
base_text.push_str(line);
base_text.push('\n');
continue;
}
if trimmed.starts_with('[') && trimmed.ends_with(']') && trimmed.len() >= 2 {
current_table = Some(trimmed[1..trimmed.len() - 1].trim().to_string());
in_array_table = false;
base_text.push_str(line);
base_text.push('\n');
continue;
}
if trimmed.is_empty() || trimmed.starts_with('#') {
base_text.push_str(line);
base_text.push('\n');
continue;
}
if let Some(plus_idx) = trimmed.find("+=") {
if in_array_table {
anyhow::bail!(
"'+=' is not supported inside array-of-table sections ({})",
current_table.as_deref().unwrap_or("")
);
}
let key = trimmed[..plus_idx].trim();
let val_str = trimmed[plus_idx + 2..].trim();
let val: toml::Value = toml::from_str::<toml::Value>(&format!("v = {}", val_str))
.context("Failed to parse append value")?
.get("v")
.cloned()
.unwrap();
let full_key = if key.contains('.') {
key.to_string()
} else if let Some(table) = current_table.as_deref() {
format!("{}.{}", table, key)
} else {
key.to_string()
};
appends.entry(full_key).or_insert_with(Vec::new).push(val);
// Preserve line numbering for parser diagnostics.
base_text.push('\n');
continue;
}
base_text.push_str(line);
base_text.push('\n');
}
Ok((base_text, appends))
}
#[cfg(test)]
mod spec_tests {
use super::*;
@@ -1223,6 +1341,7 @@ type = "custom"
[flags]
cc = "my-cc"
cflags = ["-O2"]
cxxflags = ["-O2", "-pipe"]
passthrough_env = ["RUSTFLAGS"]
make_vars = ["V=1"]
make_dirs = ["lib"]
@@ -1242,6 +1361,10 @@ post_configure = ["echo configured"]
"build.flags.cflags".to_string(),
vec![toml::Value::String("-g".to_string())],
);
config.appends.insert(
"build.flags.cxxflags".to_string(),
vec![toml::Value::String("-stdlib=libc++".to_string())],
);
config.appends.insert(
"build.flags.rustflags".to_string(),
vec![toml::Value::Array(vec![
@@ -1301,6 +1424,14 @@ post_configure = ["echo configured"]
assert_eq!(spec.build.flags.cc, "my-cc");
assert!(spec.build.flags.cflags.contains(&"-O2".to_string()));
assert!(spec.build.flags.cflags.contains(&"-g".to_string()));
assert!(spec.build.flags.cxxflags.contains(&"-O2".to_string()));
assert!(spec.build.flags.cxxflags.contains(&"-pipe".to_string()));
assert!(
spec.build
.flags
.cxxflags
.contains(&"-stdlib=libc++".to_string())
);
assert!(spec.build.flags.rustflags.contains(&"-C".to_string()));
assert!(
spec.build
@@ -1656,6 +1787,51 @@ keep = ["etc/locale.gen", "etc/resolv.conf"]
);
}
#[test]
fn parse_build_flags_appends_from_spec_file() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("pkg.toml");
std::fs::write(
&path,
r#"
[package]
name = "foo"
version = "1.0"
description = "d"
homepage = "h"
license = "MIT"
[source]
url = "https://example.com/foo.tar.gz"
sha256 = "skip"
extract_dir = "foo"
[build]
type = "custom"
[build.flags]
cxxflags = ["-O2"]
cxxflags += [ "-Wno-gnu-statement-expression-from-macro-expansion" ]
ldflags += "-Wl,--as-needed"
"#,
)
.unwrap();
let spec = PackageSpec::from_file(&path).unwrap();
assert_eq!(
spec.build.flags.cxxflags,
vec![
"-O2".to_string(),
"-Wno-gnu-statement-expression-from-macro-expansion".to_string()
]
);
assert_eq!(
spec.build.flags.ldflags,
vec!["-Wl,--as-needed".to_string()]
);
}
#[test]
fn parse_passthrough_env_from_spec() {
let tmp = tempfile::tempdir().unwrap();
@@ -1829,8 +2005,12 @@ cbuild = "x86_64-pc-linux-gnu"
.unwrap(),
package_overrides: toml::Value::Table(toml::map::Map::new()),
appends: std::collections::HashMap::new(),
repo_settings: crate::config::RepoSettings::default(),
source_repos: std::collections::BTreeMap::new(),
binary_repos: std::collections::BTreeMap::new(),
mirrors: std::collections::HashMap::new(),
repo_clone_dir: PathBuf::from("/tmp"),
package_cache_dir: PathBuf::from("/tmp"),
};
spec.apply_config(&config);
@@ -2124,6 +2304,9 @@ pub enum BuildType {
pub struct BuildFlags {
#[serde(default, deserialize_with = "deserialize_string_or_array")]
pub cflags: Vec<String>,
/// Extra flags exported to `CXXFLAGS`.
#[serde(default, deserialize_with = "deserialize_string_or_array")]
pub cxxflags: Vec<String>,
#[serde(default, deserialize_with = "deserialize_string_or_array")]
pub ldflags: Vec<String>,
/// Keep existing files and install package-provided replacement as `<path>.depotnew`.
@@ -2328,6 +2511,7 @@ impl Default for BuildFlags {
fn default() -> Self {
BuildFlags {
cflags: Vec::new(),
cxxflags: Vec::new(),
ldflags: Vec::new(),
keep: Vec::new(),
no_flags: false,
+710
View File
@@ -0,0 +1,710 @@
//! Dependency resolver and execution planner.
//!
//! Builds a recursive dependency closure for install/build operations, resolves
//! dependencies by exact package name or provided feature, and returns a
//! topologically sorted execution plan.
use crate::config::Config;
use crate::db;
use crate::deps;
use crate::index::PackageIndex;
use crate::package::{BuildType, PackageSpec};
use crate::ui;
use anyhow::{Context, Result};
use petgraph::algo::toposort;
use petgraph::graph::{DiGraph, NodeIndex};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub(crate) enum PlanOrigin {
Installed,
Source {
path: PathBuf,
local_sibling: bool,
},
Binary {
repo_name: String,
record: Box<db::repo::BinaryRepoPackageRecord>,
},
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub(crate) enum PlanAction {
SkipInstalled,
BuildAndInstall,
InstallBinary,
}
#[derive(Debug, Clone)]
pub(crate) struct PlannedStep {
pub package: String,
pub action: PlanAction,
pub origin: PlanOrigin,
pub requested_by: Vec<String>,
}
impl PlannedStep {
pub(crate) fn download_size(&self) -> Option<u64> {
match &self.origin {
PlanOrigin::Binary { record, .. } => Some(record.size),
_ => None,
}
}
}
#[derive(Debug, Clone, Default)]
pub(crate) struct PlanSummary {
pub total_packages: usize,
pub skipped_installed: usize,
pub binary_installs: usize,
pub source_build_installs: usize,
pub known_download_bytes: u64,
}
#[derive(Debug, Clone)]
pub(crate) struct ExecutionPlan {
pub steps: Vec<PlannedStep>,
}
impl ExecutionPlan {
pub(crate) fn summary(&self) -> PlanSummary {
let mut out = PlanSummary {
total_packages: self.steps.len(),
..PlanSummary::default()
};
for step in &self.steps {
match step.action {
PlanAction::SkipInstalled => out.skipped_installed += 1,
PlanAction::InstallBinary => out.binary_installs += 1,
PlanAction::BuildAndInstall => out.source_build_installs += 1,
}
if let Some(n) = step.download_size() {
out.known_download_bytes = out.known_download_bytes.saturating_add(n);
}
}
out
}
pub(crate) fn actionable_steps(&self) -> impl Iterator<Item = &PlannedStep> {
self.steps
.iter()
.filter(|s| !matches!(s.action, PlanAction::SkipInstalled))
}
}
#[derive(Debug, Clone)]
pub(crate) enum InstallTarget {
PackageName(String),
SpecPath(PathBuf),
}
#[derive(Debug, Clone)]
pub(crate) struct PlannerOptions {
pub assume_yes: bool,
pub prefer_binary: bool,
pub local_sibling_root: Option<PathBuf>,
}
#[derive(Debug, Clone)]
enum CandidateKind {
Source {
path: PathBuf,
local_sibling: bool,
},
Binary {
repo_name: String,
record: Box<db::repo::BinaryRepoPackageRecord>,
},
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
enum MatchKind {
Exact,
Provides,
}
#[derive(Debug, Clone)]
struct Candidate {
package: String,
kind: CandidateKind,
match_kind: MatchKind,
sort_repo_priority: i32,
sort_label: String,
}
#[derive(Debug, Clone)]
struct NodeData {
step: PlannedStep,
}
#[derive(Debug, Clone)]
struct LocalSpecHit {
spec_name: String,
path: PathBuf,
provides: Vec<String>,
}
struct Resolver<'a> {
config: &'a Config,
rootfs: &'a Path,
db_path: PathBuf,
opts: PlannerOptions,
pkg_index: PackageIndex,
graph: DiGraph<NodeData, ()>,
by_package: HashMap<String, NodeIndex>,
spec_cache: HashMap<PathBuf, PackageSpec>,
local_sibling_cache: BTreeMap<PathBuf, Vec<LocalSpecHit>>,
stack: Vec<String>,
emitted_installed_roots: HashSet<String>,
}
impl<'a> Resolver<'a> {
fn new(config: &'a Config, rootfs: &'a Path, opts: PlannerOptions) -> Self {
let db_path = config.db_dir.join("packages.db");
let pkg_index = PackageIndex::build_with_repo_dir(Some(config.repo_clone_dir.clone()));
Self {
config,
rootfs,
db_path,
opts,
pkg_index,
graph: DiGraph::new(),
by_package: HashMap::new(),
spec_cache: HashMap::new(),
local_sibling_cache: BTreeMap::new(),
stack: Vec::new(),
emitted_installed_roots: HashSet::new(),
}
}
fn plan_for_install_target(mut self, target: InstallTarget) -> Result<ExecutionPlan> {
match target {
InstallTarget::PackageName(name) => {
if deps::is_dep_satisfied_in_db(&name, &self.db_path)? {
if self.emitted_installed_roots.insert(name.clone()) {
self.add_installed_root_step(name, "requested package".to_string());
}
} else {
let root =
self.resolve_dep_node(&name, None, "requested package".to_string())?;
if let Some(root_idx) = root {
self.mark_requested_by(root_idx, "requested package".to_string());
}
}
}
InstallTarget::SpecPath(path) => {
let root_idx =
self.ensure_source_spec_node(&path, true, true, "requested spec".to_string())?;
self.mark_requested_by(root_idx, "requested spec".to_string());
}
}
self.finish_plan()
}
fn plan_for_deps(mut self, deps_to_install: &[String]) -> Result<ExecutionPlan> {
for dep in deps_to_install {
if deps::is_dep_satisfied_in_db(dep, &self.db_path)? {
continue;
}
if let Some(idx) = self.resolve_dep_node(dep, None, format!("dependency {}", dep))? {
self.mark_requested_by(idx, format!("dependency {}", dep));
}
}
self.finish_plan()
}
fn finish_plan(self) -> Result<ExecutionPlan> {
let order = toposort(&self.graph, None)
.map_err(|_| anyhow::anyhow!("Dependency cycle detected in plan graph"))?;
let mut steps = Vec::with_capacity(order.len());
for idx in order {
let node = self
.graph
.node_weight(idx)
.with_context(|| format!("Missing plan node {idx:?}"))?;
steps.push(node.step.clone());
}
Ok(ExecutionPlan { steps })
}
fn add_installed_root_step(&mut self, package: String, requested_by: String) {
let step = PlannedStep {
package,
action: PlanAction::SkipInstalled,
origin: PlanOrigin::Installed,
requested_by: vec![requested_by],
};
self.graph.add_node(NodeData { step });
}
fn mark_requested_by(&mut self, idx: NodeIndex, reason: String) {
if let Some(node) = self.graph.node_weight_mut(idx)
&& !node.step.requested_by.contains(&reason)
{
node.step.requested_by.push(reason);
}
}
fn ensure_source_spec_node(
&mut self,
path: &Path,
allow_local_sibling_fallback: bool,
local_sibling: bool,
requested_by: String,
) -> Result<NodeIndex> {
let (package_name, deps_needed) = {
let spec = self.load_spec(path)?;
(spec.package.name.clone(), source_deps_for_install(spec))
};
if let Some(&idx) = self.by_package.get(&package_name) {
self.mark_requested_by(idx, requested_by);
return Ok(idx);
}
self.push_stack(&package_name)?;
let idx = self.graph.add_node(NodeData {
step: PlannedStep {
package: package_name.clone(),
action: PlanAction::BuildAndInstall,
origin: PlanOrigin::Source {
path: path.to_path_buf(),
local_sibling,
},
requested_by: vec![requested_by],
},
});
self.by_package.insert(package_name.clone(), idx);
for dep in deps_needed {
if let Some(dep_idx) = self.resolve_dep_node(
&dep,
allow_local_sibling_fallback.then_some(path),
format!("{} needs {}", package_name, dep),
)? {
self.graph.add_edge(dep_idx, idx, ());
}
}
self.pop_stack(&package_name);
Ok(idx)
}
fn resolve_dep_node(
&mut self,
dep: &str,
requester_spec_path: Option<&Path>,
requested_by: String,
) -> Result<Option<NodeIndex>> {
if deps::is_dep_satisfied_in_db(dep, &self.db_path)? {
return Ok(None);
}
let dep_name = deps::dep_name(dep);
let candidates = self.collect_candidates(dep_name, requester_spec_path)?;
if candidates.is_empty() {
if let Some(spec_path) = requester_spec_path {
anyhow::bail!(
"Could not resolve dependency '{}' (from {}). Checked binary repos, source repos, and local sibling specs",
dep,
spec_path.display()
);
}
anyhow::bail!(
"Could not resolve dependency '{}'. Checked binary repos and source repos",
dep
);
}
let chosen = self.choose_candidate(dep, &candidates)?;
let idx = match chosen.kind {
CandidateKind::Source {
ref path,
local_sibling,
} => self.ensure_source_spec_node(path, true, local_sibling, requested_by)?,
CandidateKind::Binary {
ref repo_name,
ref record,
} => self.ensure_binary_node(repo_name, (**record).clone(), requested_by)?,
};
Ok(Some(idx))
}
fn ensure_binary_node(
&mut self,
repo_name: &str,
record: db::repo::BinaryRepoPackageRecord,
requested_by: String,
) -> Result<NodeIndex> {
if let Some(&idx) = self.by_package.get(&record.name) {
self.mark_requested_by(idx, requested_by);
return Ok(idx);
}
self.push_stack(&record.name)?;
let idx = self.graph.add_node(NodeData {
step: PlannedStep {
package: record.name.clone(),
action: PlanAction::InstallBinary,
origin: PlanOrigin::Binary {
repo_name: repo_name.to_string(),
record: Box::new(record.clone()),
},
requested_by: vec![requested_by],
},
});
self.by_package.insert(record.name.clone(), idx);
for dep in &record.runtime_dependencies {
if let Some(dep_idx) =
self.resolve_dep_node(dep, None, format!("{} needs {}", record.name, dep))?
{
self.graph.add_edge(dep_idx, idx, ());
}
}
self.pop_stack(&record.name);
Ok(idx)
}
fn choose_candidate(&self, dep: &str, candidates: &[Candidate]) -> Result<Candidate> {
let mut sorted = candidates.to_vec();
sorted.sort_by(|a, b| {
candidate_sort_key(a, self.opts.prefer_binary)
.cmp(&candidate_sort_key(b, self.opts.prefer_binary))
});
if sorted.len() == 1 {
return Ok(sorted.remove(0));
}
if self.opts.assume_yes {
let chosen = sorted.remove(0);
ui::info(format!(
"Multiple providers matched '{}' - using {} ({}) due to --yes",
dep, chosen.package, chosen.sort_label
));
return Ok(chosen);
}
let options: Vec<String> = sorted.iter().map(format_candidate_label).collect();
let prompt = format!("Multiple packages satisfy '{}'. Choose one", dep);
let choice = ui::prompt_select_index(&prompt, &options, 0)?;
Ok(sorted.remove(choice))
}
fn collect_candidates(
&mut self,
dep_name: &str,
requester_spec_path: Option<&Path>,
) -> Result<Vec<Candidate>> {
let mut out = Vec::new();
let mut seen = HashSet::<String>::new();
// Binary repos
let host_arch = std::env::consts::ARCH;
let mut binary_repos: Vec<_> = self
.config
.binary_repos
.iter()
.filter(|(_, repo)| repo.enabled && repo.supports_arch(host_arch))
.collect();
binary_repos.sort_by(|a, b| a.1.priority.cmp(&b.1.priority).then_with(|| a.0.cmp(b.0)));
for (repo_name, repo_cfg) in binary_repos {
match db::repo::find_binary_repo_packages(
repo_name,
repo_cfg,
self.rootfs,
&self.config.package_cache_dir,
dep_name,
) {
Ok(records) => {
for rec in records {
let match_kind = if rec.name.eq_ignore_ascii_case(dep_name) {
MatchKind::Exact
} else {
MatchKind::Provides
};
let key = format!("bin:{}:{}", repo_name, rec.name);
if !seen.insert(key) {
continue;
}
out.push(Candidate {
package: rec.name.clone(),
kind: CandidateKind::Binary {
repo_name: repo_name.clone(),
record: Box::new(rec.clone()),
},
match_kind,
sort_repo_priority: repo_cfg.priority,
sort_label: format!("binary:{}", repo_name),
});
}
}
Err(e) => crate::log_warn!("Binary repo '{}': {}", repo_name, e),
}
}
// Global source index
if let Some(path) = self.pkg_index.find(dep_name) {
let spec = self.load_spec(&path)?;
let match_kind = if spec.package.name.eq_ignore_ascii_case(dep_name) {
MatchKind::Exact
} else {
MatchKind::Provides
};
let local_sibling = false;
let key = format!("src:{}", path.display());
if seen.insert(key) {
out.push(Candidate {
package: spec.package.name.clone(),
kind: CandidateKind::Source {
path: path.clone(),
local_sibling,
},
match_kind,
sort_repo_priority: 0,
sort_label: format!("source:{}", source_label_for_path(self.config, &path)),
});
}
}
// Additional source providers from index (for provider prompt)
for path in self.pkg_index.find_providers(dep_name) {
let spec = self.load_spec(&path)?;
let key = format!("src:{}", path.display());
if !seen.insert(key) {
continue;
}
out.push(Candidate {
package: spec.package.name.clone(),
kind: CandidateKind::Source {
path: path.clone(),
local_sibling: false,
},
match_kind: if spec.package.name.eq_ignore_ascii_case(dep_name) {
MatchKind::Exact
} else {
MatchKind::Provides
},
sort_repo_priority: 0,
sort_label: format!("source:{}", source_label_for_path(self.config, &path)),
});
}
// Local sibling fallback (e.g. ../foo/*.toml when building from a local tree)
let local_sibling_root = requester_spec_path
.and_then(|p| p.parent())
.and_then(|p| p.parent())
.map(Path::to_path_buf)
.or_else(|| self.opts.local_sibling_root.clone());
if let Some(root) = local_sibling_root {
for hit in self.local_sibling_hits(&root)? {
let exact = hit.spec_name.eq_ignore_ascii_case(dep_name);
let provides = hit
.provides
.iter()
.any(|p| p.eq_ignore_ascii_case(dep_name));
if !(exact || provides) {
continue;
}
let key = format!("src:{}", hit.path.display());
if !seen.insert(key) {
continue;
}
out.push(Candidate {
package: hit.spec_name.clone(),
kind: CandidateKind::Source {
path: hit.path.clone(),
local_sibling: true,
},
match_kind: if exact {
MatchKind::Exact
} else {
MatchKind::Provides
},
sort_repo_priority: -10,
sort_label: "source:local-sibling".to_string(),
});
}
}
Ok(out)
}
fn load_spec(&mut self, path: &Path) -> Result<&PackageSpec> {
if !self.spec_cache.contains_key(path) {
let mut spec = PackageSpec::from_file(path)
.with_context(|| format!("Failed to parse spec {}", path.display()))?;
spec.apply_config(self.config);
self.spec_cache.insert(path.to_path_buf(), spec);
}
self.spec_cache
.get(path)
.with_context(|| format!("Spec cache entry missing for {}", path.display()))
}
fn local_sibling_hits(&mut self, root: &Path) -> Result<&[LocalSpecHit]> {
if !self.local_sibling_cache.contains_key(root) {
let mut hits = Vec::new();
for entry in walkdir::WalkDir::new(root).min_depth(1).max_depth(4) {
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
let path = entry.path();
if !entry.file_type().is_file() {
continue;
}
if path.extension().is_none_or(|ext| ext != "toml") {
continue;
}
if let Ok(mut spec) = PackageSpec::from_file(path) {
spec.apply_config(self.config);
hits.push(LocalSpecHit {
spec_name: spec.package.name.clone(),
path: path.to_path_buf(),
provides: spec.alternatives.provides.clone(),
});
}
}
self.local_sibling_cache.insert(root.to_path_buf(), hits);
}
Ok(self
.local_sibling_cache
.get(root)
.map(Vec::as_slice)
.unwrap_or(&[]))
}
fn push_stack(&mut self, pkg: &str) -> Result<()> {
if self.stack.iter().any(|s| s == pkg) {
let mut chain = self.stack.join(" -> ");
if !chain.is_empty() {
chain.push_str(" -> ");
}
chain.push_str(pkg);
anyhow::bail!("Dependency cycle detected: {}", chain);
}
self.stack.push(pkg.to_string());
Ok(())
}
fn pop_stack(&mut self, pkg: &str) {
if matches!(self.stack.last(), Some(last) if last == pkg) {
self.stack.pop();
}
}
}
fn source_deps_for_install(spec: &PackageSpec) -> Vec<String> {
let mut deps_all = Vec::new();
for dep in &spec.dependencies.build {
push_unique(&mut deps_all, dep.clone());
}
for dep in &spec.dependencies.runtime {
push_unique(&mut deps_all, dep.clone());
}
if matches!(spec.build.build_type, BuildType::Autotools) {
for dep in &spec.dependencies.test {
push_unique(&mut deps_all, dep.clone());
}
}
for out in spec.outputs() {
let deps = spec.dependencies_for_output(&out.name);
for dep in deps.runtime {
push_unique(&mut deps_all, dep);
}
}
deps_all
}
fn push_unique(v: &mut Vec<String>, item: String) {
if !v.contains(&item) {
v.push(item);
}
}
fn source_label_for_path(config: &Config, path: &Path) -> String {
if let Ok(rel) = path.strip_prefix(&config.repo_clone_dir)
&& let Some(repo) = rel.components().next()
{
return repo.as_os_str().to_string_lossy().into_owned();
}
"local".to_string()
}
fn format_candidate_label(c: &Candidate) -> String {
let match_label = match c.match_kind {
MatchKind::Exact => "exact",
MatchKind::Provides => "provides",
};
match &c.kind {
CandidateKind::Source {
path,
local_sibling,
} => format!(
"{} [{}] {} {}",
c.package,
if *local_sibling {
"source:local-sibling"
} else {
&c.sort_label
},
match_label,
path.display()
),
CandidateKind::Binary { repo_name, record } => format!(
"{} [binary:{}] {} {}-{} size={} file={}",
c.package,
repo_name,
match_label,
record.version,
record.revision,
record.size,
record.filename
),
}
}
fn candidate_sort_key(c: &Candidate, prefer_binary: bool) -> (i32, i32, i32, String, String) {
let is_binary = matches!(c.kind, CandidateKind::Binary { .. });
let kind_rank = match (prefer_binary, is_binary) {
(true, true) => 0,
(true, false) => 1,
(false, false) => 0,
(false, true) => 1,
};
let match_rank = match c.match_kind {
MatchKind::Exact => 0,
MatchKind::Provides => 1,
};
(
kind_rank,
match_rank,
c.sort_repo_priority,
c.package.clone(),
c.sort_label.clone(),
)
}
pub(crate) fn build_install_plan(
config: &Config,
rootfs: &Path,
target: InstallTarget,
opts: PlannerOptions,
) -> Result<ExecutionPlan> {
Resolver::new(config, rootfs, opts).plan_for_install_target(target)
}
pub(crate) fn build_dependency_install_plan(
config: &Config,
rootfs: &Path,
deps_to_install: &[String],
opts: PlannerOptions,
) -> Result<ExecutionPlan> {
Resolver::new(config, rootfs, opts).plan_for_deps(deps_to_install)
}
+320
View File
@@ -0,0 +1,320 @@
//! Minisign-based detached signature helpers.
use anyhow::{Context, Result};
use minisign::{PublicKey, SecretKeyBox, SignatureBox};
use std::fs;
use std::path::{Path, PathBuf};
const PUBLIC_KEYS_DIR_REL: &str = "usr/share/depot/keys/public";
const SIGN_KEYS_DIR_REL: &str = "usr/share/depot/keys/sign";
#[derive(Debug, Clone, Default)]
pub struct KeyLocations {
pub public_key: Option<PathBuf>,
pub signing_key: Option<PathBuf>,
}
fn is_zst_file(path: &Path) -> bool {
path.file_name()
.map(|n| n.to_string_lossy().ends_with(".zst"))
.unwrap_or(false)
}
fn candidate_roots(rootfs: &Path, host_root: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
out.push(rootfs.to_path_buf());
if rootfs != host_root {
out.push(host_root.to_path_buf());
}
out
}
fn key_dir_candidates(rootfs: &Path, host_root: &Path, rel: &str) -> Vec<PathBuf> {
candidate_roots(rootfs, host_root)
.into_iter()
.map(|root| root.join(rel))
.collect()
}
fn pick_key_file(dir: &Path, is_public: bool) -> Result<Option<PathBuf>> {
if !dir.exists() {
return Ok(None);
}
if !dir.is_dir() {
anyhow::bail!("Key path is not a directory: {}", dir.display());
}
let mut files: Vec<PathBuf> = fs::read_dir(dir)
.with_context(|| format!("Failed to read {}", dir.display()))?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.is_file())
.collect();
if files.is_empty() {
return Ok(None);
}
files.sort();
let preferred = if is_public {
["depot.pub", "depot.minisign.pub", "minisign.pub"]
} else {
["depot.key", "depot.minisign.key", "minisign.key"]
};
for name in preferred {
if let Some(found) = files
.iter()
.find(|p| p.file_name().is_some_and(|n| n == name))
.cloned()
{
return Ok(Some(found));
}
}
let ext = if is_public { "pub" } else { "key" };
let ext_matches: Vec<PathBuf> = files
.iter()
.filter(|p| p.extension().is_some_and(|e| e == ext))
.cloned()
.collect();
if ext_matches.len() == 1 {
return Ok(Some(ext_matches[0].clone()));
}
if files.len() == 1 {
return Ok(Some(files[0].clone()));
}
anyhow::bail!(
"Ambiguous {} key directory {} (multiple key files found)",
if is_public { "public" } else { "signing" },
dir.display()
);
}
fn locate_keys_in_roots(rootfs: &Path, host_root: &Path) -> Result<KeyLocations> {
let mut keys = KeyLocations::default();
for dir in key_dir_candidates(rootfs, host_root, PUBLIC_KEYS_DIR_REL) {
if let Some(path) = pick_key_file(&dir, true)? {
keys.public_key = Some(path);
break;
}
}
for dir in key_dir_candidates(rootfs, host_root, SIGN_KEYS_DIR_REL) {
if let Some(path) = pick_key_file(&dir, false)? {
keys.signing_key = Some(path);
break;
}
}
Ok(keys)
}
/// Locate public/signing keys by checking both `rootfs` and the host `/`.
pub fn locate_keys(rootfs: &Path) -> Result<KeyLocations> {
locate_keys_in_roots(rootfs, Path::new("/"))
}
fn detached_sig_path(input: &Path) -> PathBuf {
PathBuf::from(format!("{}.sig", input.display()))
}
fn sign_detached_with_key_paths(input: &Path, sig_path: &Path, keys: &KeyLocations) -> Result<()> {
if !input.exists() {
anyhow::bail!("File not found: {}", input.display());
}
if !is_zst_file(input) {
anyhow::bail!(
"Signing command currently only supports .zst files: {}",
input.display()
);
}
let signing_key_path = keys.signing_key.as_ref().with_context(
|| "No minisign signing key found in /usr/share/depot/keys/sign (checked rootfs and host)",
)?;
let secret_key_text = fs::read_to_string(signing_key_path).with_context(|| {
format!(
"Failed to read minisign signing key: {}",
signing_key_path.display()
)
})?;
let secret_key_box = SecretKeyBox::from_string(&secret_key_text).with_context(|| {
format!(
"Invalid minisign signing key file: {}",
signing_key_path.display()
)
})?;
let secret_key = match secret_key_box.clone().into_unencrypted_secret_key() {
Ok(sk) => sk,
Err(_) => secret_key_box.into_secret_key(None).with_context(|| {
format!(
"Failed to load minisign signing key: {}",
signing_key_path.display()
)
})?,
};
let public_key = if let Some(path) = &keys.public_key {
Some(
PublicKey::from_file(path).with_context(|| {
format!("Failed to load minisign public key: {}", path.display())
})?,
)
} else {
crate::log_warn!(
"No minisign public key found in /usr/share/depot/keys/public (checked rootfs and host)"
);
None
};
let file =
fs::File::open(input).with_context(|| format!("Failed to open {}", input.display()))?;
let sig = minisign::sign(
public_key.as_ref(),
&secret_key,
file,
None,
Some(&format!("depot signature for {}", input.display())),
)
.with_context(|| format!("Failed to sign {}", input.display()))?;
fs::write(sig_path, sig.to_bytes())
.with_context(|| format!("Failed to write detached signature {}", sig_path.display()))?;
Ok(())
}
fn verify_detached_with_key_paths(
input: &Path,
sig_path: &Path,
keys: &KeyLocations,
) -> Result<()> {
if !input.exists() {
anyhow::bail!("File not found: {}", input.display());
}
if !sig_path.exists() {
anyhow::bail!("Detached signature not found: {}", sig_path.display());
}
if !is_zst_file(input) {
anyhow::bail!(
"Verification currently only supports .zst files: {}",
input.display()
);
}
let public_key_path = keys.public_key.as_ref().with_context(
|| "No minisign public key found in /usr/share/depot/keys/public (checked rootfs and host)",
)?;
let public_key = PublicKey::from_file(public_key_path).with_context(|| {
format!(
"Failed to load minisign public key: {}",
public_key_path.display()
)
})?;
let sig = SignatureBox::from_file(sig_path)
.with_context(|| format!("Failed to load detached signature: {}", sig_path.display()))?;
let mut file =
fs::File::open(input).with_context(|| format!("Failed to open {}", input.display()))?;
minisign::verify(&public_key, &sig, &mut file, true, false, false).with_context(|| {
format!(
"Detached signature verification failed for {}",
input.display()
)
})?;
Ok(())
}
/// Sign a `.zst` file with a detached minisign signature written to `<file>.sig`.
pub fn sign_zst_file_detached(rootfs: &Path, input: &Path) -> Result<PathBuf> {
let keys = locate_keys(rootfs)?;
let sig_path = detached_sig_path(input);
sign_detached_with_key_paths(input, &sig_path, &keys)?;
Ok(sig_path)
}
/// Verify a `.zst` file using a detached minisign signature.
pub fn verify_zst_file_detached(rootfs: &Path, input: &Path, sig_path: &Path) -> Result<()> {
let keys = locate_keys(rootfs)?;
verify_detached_with_key_paths(input, sig_path, &keys)
}
/// Attempt to sign a `.zst` file using discovered keys; skip if no signing key exists.
pub fn auto_sign_zst_file_detached(rootfs: &Path, input: &Path) -> Result<Option<PathBuf>> {
if !is_zst_file(input) {
return Ok(None);
}
let keys = locate_keys(rootfs)?;
if keys.signing_key.is_none() {
crate::log_info!(
"No minisign signing key found; skipping detached signature for {}",
input.display()
);
return Ok(None);
}
let sig_path = detached_sig_path(input);
sign_detached_with_key_paths(input, &sig_path, &keys)?;
Ok(Some(sig_path))
}
#[cfg(test)]
mod tests {
use super::*;
use minisign::KeyPair;
use std::io::Write;
fn write_test_keys(root: &Path) -> Result<(PathBuf, PathBuf)> {
let public_dir = root.join(PUBLIC_KEYS_DIR_REL);
let sign_dir = root.join(SIGN_KEYS_DIR_REL);
fs::create_dir_all(&public_dir)?;
fs::create_dir_all(&sign_dir)?;
let pair = KeyPair::generate_unencrypted_keypair().context("Failed to generate keypair")?;
let pub_path = public_dir.join("depot.pub");
let sign_path = sign_dir.join("depot.key");
fs::write(&pub_path, pair.pk.to_box()?.to_bytes())?;
fs::write(&sign_path, pair.sk.to_box(Some("test"))?.to_bytes())?;
Ok((pub_path, sign_path))
}
#[test]
fn locate_keys_checks_rootfs_before_host() -> Result<()> {
let rootfs = tempfile::tempdir()?;
let host = tempfile::tempdir()?;
let (rootfs_pub, rootfs_sign) = write_test_keys(rootfs.path())?;
let _ = write_test_keys(host.path())?;
let keys = locate_keys_in_roots(rootfs.path(), host.path())?;
assert_eq!(keys.public_key, Some(rootfs_pub));
assert_eq!(keys.signing_key, Some(rootfs_sign));
Ok(())
}
#[test]
fn sign_zst_file_writes_detached_signature() -> Result<()> {
let rootfs = tempfile::tempdir()?;
let host = tempfile::tempdir()?;
let (pub_path, sign_path) = write_test_keys(rootfs.path())?;
let file = rootfs.path().join("artifact.tar.zst");
let mut f = fs::File::create(&file)?;
f.write_all(b"test payload")?;
f.flush()?;
let keys = KeyLocations {
public_key: Some(pub_path.clone()),
signing_key: Some(sign_path.clone()),
};
let sig_path = PathBuf::from(format!("{}.sig", file.display()));
sign_detached_with_key_paths(&file, &sig_path, &keys)?;
assert!(sig_path.exists());
let pk = PublicKey::from_file(pub_path)?;
let sig_box = SignatureBox::from_file(&sig_path)?;
let mut reader = fs::File::open(&file)?;
minisign::verify(&pk, &sig_box, &mut reader, true, false, false)
.context("signature verification should succeed")?;
verify_detached_with_key_paths(&file, &sig_path, &keys)?;
// Also make sure host/rootfs lookup path version works without touching /.
let _ = locate_keys_in_roots(rootfs.path(), host.path())?;
Ok(())
}
}
+139 -8
View File
@@ -1,9 +1,11 @@
//! Git source support via libgit2 (git2 crate)
use anyhow::{Context, Result};
use git2::{Cred, FetchOptions, Oid, RemoteCallbacks, Repository};
use git2::{Cred, CredentialType, FetchOptions, Oid, RemoteCallbacks, Repository};
use inquire::Password;
use sha2::{Digest, Sha256};
use std::fs;
use std::io::{self, IsTerminal, Write};
use std::path::Path;
/// Checkout a repository URL at a specific revision into `checkout_dir`.
@@ -115,15 +117,144 @@ fn checkout_rev(repo: &Repository, rev: &str) -> Result<()> {
fn remote_callbacks() -> RemoteCallbacks<'static> {
let mut callbacks = RemoteCallbacks::new();
let mut credential_state = CredentialState::default();
// Try SSH agent / default key locations.
callbacks.credentials(|_url, username_from_url, _allowed| {
if let Some(name) = username_from_url {
Cred::ssh_key_from_agent(name)
} else {
Cred::default()
}
callbacks.credentials(move |url, username_from_url, allowed| {
credential_state.provide(url, username_from_url, allowed)
});
callbacks
}
#[derive(Default)]
struct CredentialState {
username: Option<String>,
prompted_userpass: bool,
}
impl CredentialState {
fn provide(
&mut self,
url: &str,
username_from_url: Option<&str>,
allowed: CredentialType,
) -> std::result::Result<Cred, git2::Error> {
if allowed.contains(CredentialType::USERNAME)
&& !allowed.intersects(
CredentialType::SSH_KEY
| CredentialType::USER_PASS_PLAINTEXT
| CredentialType::DEFAULT,
)
{
let username = self.username(url, username_from_url)?;
return Cred::username(&username);
}
if allowed.contains(CredentialType::SSH_KEY) {
let ssh_username = username_from_url
.or(self.username.as_deref())
.unwrap_or("git");
if let Ok(cred) = Cred::ssh_key_from_agent(ssh_username) {
return Ok(cred);
}
}
if allowed.contains(CredentialType::DEFAULT)
&& let Ok(cred) = Cred::default()
{
return Ok(cred);
}
if allowed.contains(CredentialType::USER_PASS_PLAINTEXT) {
let (username, password) = self.userpass(url, username_from_url)?;
return Cred::userpass_plaintext(&username, &password);
}
if allowed.contains(CredentialType::USERNAME) {
let username = self.username(url, username_from_url)?;
return Cred::username(&username);
}
Err(git2::Error::from_str(
"Unsupported authentication method requested by git remote",
))
}
fn username(
&mut self,
url: &str,
username_from_url: Option<&str>,
) -> std::result::Result<String, git2::Error> {
if let Some(username) = username_from_url
&& !username.trim().is_empty()
{
let username = username.trim().to_string();
self.username.get_or_insert_with(|| username.clone());
return Ok(username);
}
if let Some(username) = self.username.as_ref() {
return Ok(username.clone());
}
ensure_prompt_terminal(url)?;
crate::log_warn!("Git remote requires credentials: {}", url);
let mut input = String::new();
loop {
print!("Git username for {}: ", url);
io::stdout()
.flush()
.map_err(|e| git2::Error::from_str(&format!("Failed to flush prompt: {e}")))?;
input.clear();
io::stdin()
.read_line(&mut input)
.map_err(|e| git2::Error::from_str(&format!("Failed to read username: {e}")))?;
let trimmed = input.trim();
if !trimmed.is_empty() {
let username = trimmed.to_string();
self.username = Some(username.clone());
return Ok(username);
}
crate::log_warn!("Username cannot be empty.");
}
}
fn userpass(
&mut self,
url: &str,
username_from_url: Option<&str>,
) -> std::result::Result<(String, String), git2::Error> {
if self.prompted_userpass {
return Err(git2::Error::from_str(
"Git credentials were rejected by the remote",
));
}
let username = self.username(url, username_from_url)?;
ensure_prompt_terminal(url)?;
self.prompted_userpass = true;
let prompt = format!("Git password/token for {} ({}):", url, username);
let password = Password::new(&prompt)
.without_confirmation()
.prompt()
.map_err(|e| {
git2::Error::from_str(&format!("Failed to read git password/token: {e}"))
})?;
if password.is_empty() {
return Err(git2::Error::from_str("Git password/token cannot be empty"));
}
Ok((username, password))
}
}
fn ensure_prompt_terminal(url: &str) -> std::result::Result<(), git2::Error> {
if io::stdin().is_terminal() && io::stdout().is_terminal() {
return Ok(());
}
Err(git2::Error::from_str(&format!(
"Authentication required for {url}, but no interactive terminal is available"
)))
}
+2 -2
View File
@@ -219,7 +219,7 @@ fn auto_strip_elf_files(destdir: &Path) -> Result<usize> {
}
let status = Command::new("strip")
.arg("--strip-unneeded")
.arg("--strip-debug")
.arg(path)
.status()
.with_context(|| {
@@ -278,7 +278,7 @@ fn compress_manpages_zstd(destdir: &Path) -> Result<usize> {
let out_file = fs::File::create(&tmp_path)
.with_context(|| format!("Failed to create {}", tmp_path.display()))?;
let mut encoder = zstd::stream::write::Encoder::new(out_file, 22)
let mut encoder = zstd::stream::write::Encoder::new(out_file, 19)
.with_context(|| format!("Failed to start zstd encoder for {}", path.display()))?;
io::copy(&mut input, &mut encoder)
.with_context(|| format!("Failed to compress {}", path.display()))?;
+66
View File
@@ -2,6 +2,9 @@
use anyhow::{Context, Result};
use std::io::{self, IsTerminal, Write};
use std::sync::atomic::{AtomicBool, Ordering};
static ASSUME_YES: AtomicBool = AtomicBool::new(false);
#[derive(Clone, Copy)]
enum Stream {
@@ -88,7 +91,17 @@ fn parse_yes_no_input(input: &str, default_yes: bool) -> Option<bool> {
}
}
/// Configure global prompt behavior for the current process.
pub fn set_assume_yes(assume_yes: bool) {
ASSUME_YES.store(assume_yes, Ordering::Relaxed);
}
pub fn prompt_yes_no(prompt: &str, default_yes: bool) -> Result<bool> {
if ASSUME_YES.load(Ordering::Relaxed) {
info(format!("{} [auto-yes]", prompt));
return Ok(true);
}
let default_hint = if default_yes { "Y/n" } else { "y/N" };
loop {
print!(
@@ -114,6 +127,59 @@ pub fn prompt_yes_no(prompt: &str, default_yes: bool) -> Result<bool> {
}
}
/// Prompt the user to choose one option by index.
pub fn prompt_select_index(prompt: &str, options: &[String], default_idx: usize) -> Result<usize> {
if options.is_empty() {
anyhow::bail!("No options available for selection");
}
let default_idx = default_idx.min(options.len() - 1);
if ASSUME_YES.load(Ordering::Relaxed) {
info(format!(
"{} [auto-select {}: {}]",
prompt,
default_idx + 1,
options[default_idx]
));
return Ok(default_idx);
}
loop {
println!("{} {}", label(Stream::Stdout, "?", "35"), prompt);
for (idx, option) in options.iter().enumerate() {
println!(
" {} {}{}",
idx + 1,
option,
if idx == default_idx { " [default]" } else { "" }
);
}
print!(
"{} Select an option [1-{}] (default {}): ",
label(Stream::Stdout, "?", "35"),
options.len(),
default_idx + 1
);
io::stdout()
.flush()
.context("Failed to flush prompt to stdout")?;
let mut input = String::new();
io::stdin()
.read_line(&mut input)
.context("Failed to read user input from stdin")?;
let trimmed = input.trim();
if trimmed.is_empty() {
return Ok(default_idx);
}
if let Ok(num) = trimmed.parse::<usize>()
&& (1..=options.len()).contains(&num)
{
return Ok(num - 1);
}
warn("Please enter a valid option number.");
}
}
#[cfg(test)]
mod tests {
use super::parse_yes_no_input;
+38
View File
@@ -0,0 +1,38 @@
#!/bin/sh
set -eu
if [ "$#" -ne 6 ]; then
echo "usage: $0 <cargo> <src_root> <build_root> <profile> <release_flag> <output>" >&2
exit 2
fi
cargo_bin="$1"
src_root="$2"
build_root="$3"
profile="$4"
release_flag="$5"
output="$6"
cargo_home="$build_root/cargo-home"
cargo_target_dir="$build_root/cargo-target"
mkdir -p "$cargo_home" "$cargo_target_dir"
export CARGO_HOME="$cargo_home"
export CARGO_TARGET_DIR="$cargo_target_dir"
if [ "$release_flag" = "1" ]; then
"$cargo_bin" build --locked --manifest-path "$src_root/Cargo.toml" --release
else
"$cargo_bin" build --locked --manifest-path "$src_root/Cargo.toml"
fi
src_bin="$cargo_target_dir/$profile/depot"
if [ ! -f "$src_bin" ]; then
echo "cargo build finished but binary not found: $src_bin" >&2
exit 1
fi
mkdir -p "$(dirname "$output")"
cp "$src_bin" "$output"
chmod 0755 "$output"
+21
View File
@@ -0,0 +1,21 @@
#!/bin/sh
set -eu
if [ "$#" -ne 3 ]; then
echo "usage: $0 <cargo> <src_root> <build_root>" >&2
exit 2
fi
cargo_bin="$1"
src_root="$2"
build_root="$3"
cargo_home="$build_root/cargo-home"
cargo_target_dir="$build_root/cargo-target"
mkdir -p "$cargo_home" "$cargo_target_dir"
export CARGO_HOME="$cargo_home"
export CARGO_TARGET_DIR="$cargo_target_dir"
exec "$cargo_bin" test --locked --manifest-path "$src_root/Cargo.toml"