refactor command flow and add signed package/hook safety

- split CLI definitions and command execution out of main.rs into new cli.rs and commands.rs modules\n- add transaction hook support via /etc/depot.d/hooks/*.toml with pre/post phases, operation/package/path filters, wildcard negation, and optional affected-path stdin\n- execute transaction hooks around install/update/remove operations and add package-action prompts for install/build/remove flows\n- require and verify detached minisign signatures for binary package archives when allow_unsigned=false, including cache handling and tests\n- persist optional dependencies from package metadata into repo index data and include optional deps in produced metadata\n- update dependency/planner logic to ignore dependencies provided by local split outputs and stop pulling test deps into install planning\n- auto-disable tests when required test dependencies are missing instead of hard-failing build/install paths\n- improve autotools defaults by injecting standard install directory flags when supported and preserving user overrides\n- remove static .a archives during staging by default with new build.flags.no_delete_static escape hatch\n- preserve symlinks and file metadata (permissions/timestamps) in extractor fallback copy path and add regression tests\n- refresh README docs for detached package signatures, optional dependencies, and transaction hooks\n- adjust dependency set: add filetime, disable reqwest default features, and simplify git2/OpenSSL linkage
This commit is contained in:
2026-02-28 00:54:08 -06:00
parent e045ae78fd
commit ce5fefa833
19 changed files with 3863 additions and 2923 deletions
Generated
+115 -689
View File
File diff suppressed because it is too large Load Diff
+4 -5
View File
@@ -12,12 +12,10 @@ ar = "0.9.0"
bzip2 = "0.6.1"
clap = { version = "4.5.60", features = ["derive"] }
flate2 = "1.1.9"
git2 = { version = "0.20.4", features = ["openssl-sys"] }
git2 = "0.20.4"
indicatif = "0.18.4"
nix = { version = "0.31.1", features = ["user"] }
reqwest = { version = "0.13.2", features = ["blocking", "native-tls"] }
nix = { version = "0.31.2", features = ["user"] }
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"
@@ -31,13 +29,14 @@ walkdir = "2.5.0"
xz2 = "0.1.7"
zip = "8.1.0"
zstd = { version = "0.13.3", features = ["zstdmt"] }
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"
reqwest = { version = "0.13.2", default-features = false, features = ["blocking", "native-tls"] }
filetime = "0.2.27"
[dev-dependencies]
tempfile = "=3.25.0"
+27 -1
View File
@@ -39,9 +39,11 @@ depot install zlib-1.2.11-1-x86_64.depot.pkg.tar.zst
- 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.
- Binary package installs verify both checksums and detached minisign signatures (`.sig`).
- `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.
- Resolves and offers to install missing build dependencies before fetching/building.
- Missing test dependencies automatically disable test execution for that build.
- `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.
@@ -75,6 +77,7 @@ flags = { configure = ["--enable-feature"] }
[dependencies]
build = ["gcc", "make"]
runtime = ["libc"]
optional = ["bash-completion"]
```
## Configuration
@@ -83,3 +86,26 @@ Depot can be configured via `/etc/depot.d/` (or relative to the rootfs).
- `/etc/depot.d/build.toml`: System-wide build overrides and flag appends.
- `/etc/depot.d/package.toml`: Package-specific overrides.
- `/etc/depot.d/hooks/*.toml`: Transaction hooks for `install`/`update`/`remove` pre/post phases.
### Transaction Hooks
Hook files are TOML and use Starpack-like sections:
```toml
[hook]
name = "refresh-cache"
[when]
phase = "post"
operation = ["install", "update"]
packages = ["glibc", "linux*"]
paths = ["usr/lib/*"]
negation = ["usr/lib/debug/*"]
[exec]
command = "ldconfig"
needs_paths = true
```
`needs_paths = true` passes affected paths to the command via stdin (newline-separated).
+106 -11
View File
@@ -65,11 +65,14 @@ pub fn build(
crate::builder::prepare_tool_command(&mut configure_cmd, &env_vars);
configure_cmd.arg(format!("--prefix={}", flags.prefix));
// Some projects use non-GNU configure scripts that reject --host/--build.
// Probe support first and only add these options when advertised.
let help_text = configure_help_text(&configure_path, &build_dir, &env_vars);
configure_cmd.arg(format!("--prefix={}", flags.prefix));
for default_dir_arg in default_configure_install_dirs(flags, help_text.as_deref()) {
configure_cmd.arg(default_dir_arg);
}
let supports_host =
configure_supports_option(help_text.as_deref(), "--host", &flags.configure_file);
let supports_build =
@@ -114,15 +117,6 @@ pub fn build(
}
}
if flags.lib32_variant {
if !has_configure_option_prefix(&flags.configure, "--libdir") {
configure_cmd.arg("--libdir=/usr/lib32");
}
if !has_configure_option_prefix(&flags.configure, "--libexecdir") {
configure_cmd.arg("--libexecdir=/usr/lib32");
}
}
for arg in &flags.configure {
let expanded = expand_configure_arg(spec, arg, &env_vars);
configure_cmd.arg(expanded);
@@ -439,6 +433,41 @@ fn has_configure_option_prefix(args: &[String], option: &str) -> bool {
})
}
fn default_configure_install_dirs(
flags: &crate::package::BuildFlags,
help_text: Option<&str>,
) -> Vec<String> {
let libdir = if flags.lib32_variant {
"/usr/lib32"
} else {
"/usr/lib"
};
let bindir = nonempty_trimmed(&flags.bindir).unwrap_or("/usr/bin");
let defaults = [
("--bindir", bindir),
("--sbindir", "/usr/bin"),
("--libdir", libdir),
("--libexecdir", libdir),
("--sysconfdir", "/etc"),
("--localstatedir", "/var"),
("--sharedstatedir", "/var/lib"),
("--includedir", "/usr/include"),
("--datarootdir", "/usr/share"),
("--datadir", "/usr/share"),
("--mandir", "/usr/share/man"),
("--infodir", "/usr/share/info"),
];
defaults
.iter()
.filter(|(option, _)| {
help_text.is_some_and(|text| configure_help_supports_option(text, option))
})
.filter(|(option, _)| !has_configure_option_prefix(&flags.configure, option))
.map(|(option, value)| format!("{option}={value}"))
.collect()
}
fn lib32_host_triple(host: &str) -> String {
host.replace("x86_64", "i686")
}
@@ -803,6 +832,72 @@ mod tests {
));
}
#[test]
fn test_default_configure_install_dirs_injects_expected_paths() {
let flags = BuildFlags::default();
let help = "\
--bindir=DIR
--sbindir=DIR
--libdir=DIR
--libexecdir=DIR
--sysconfdir=DIR
--localstatedir=DIR
--sharedstatedir=DIR
--includedir=DIR
--datarootdir=DIR
--datadir=DIR
--mandir=DIR
--infodir=DIR";
let args = default_configure_install_dirs(&flags, Some(help));
assert!(args.iter().any(|a| a == "--bindir=/usr/bin"));
assert!(args.iter().any(|a| a == "--sbindir=/usr/bin"));
assert!(args.iter().any(|a| a == "--libdir=/usr/lib"));
assert!(args.iter().any(|a| a == "--libexecdir=/usr/lib"));
assert!(args.iter().any(|a| a == "--sysconfdir=/etc"));
assert!(args.iter().any(|a| a == "--localstatedir=/var"));
assert!(args.iter().any(|a| a == "--sharedstatedir=/var/lib"));
assert!(args.iter().any(|a| a == "--includedir=/usr/include"));
assert!(args.iter().any(|a| a == "--datarootdir=/usr/share"));
assert!(args.iter().any(|a| a == "--datadir=/usr/share"));
assert!(args.iter().any(|a| a == "--mandir=/usr/share/man"));
assert!(args.iter().any(|a| a == "--infodir=/usr/share/info"));
}
#[test]
fn test_default_configure_install_dirs_respects_explicit_user_overrides() {
let mut flags = BuildFlags::default();
flags.configure = vec![
"--sbindir=/sbin".to_string(),
"--libdir=/custom/lib".to_string(),
"--datadir=/custom/share".to_string(),
];
let help = "--bindir=DIR --sbindir=DIR --libdir=DIR --datadir=DIR";
let args = default_configure_install_dirs(&flags, Some(help));
assert!(!args.iter().any(|a| a.starts_with("--sbindir=")));
assert!(!args.iter().any(|a| a.starts_with("--libdir=")));
assert!(!args.iter().any(|a| a.starts_with("--datadir=")));
assert!(args.iter().any(|a| a == "--bindir=/usr/bin"));
}
#[test]
fn test_default_configure_install_dirs_lib32_uses_lib32_dirs() {
let help = "--libdir=DIR --libexecdir=DIR";
let flags = BuildFlags {
lib32_variant: true,
..BuildFlags::default()
};
let args = default_configure_install_dirs(&flags, Some(help));
assert!(args.iter().any(|a| a == "--libdir=/usr/lib32"));
assert!(args.iter().any(|a| a == "--libexecdir=/usr/lib32"));
}
#[test]
fn test_default_configure_install_dirs_skips_when_not_advertised() {
let flags = BuildFlags::default();
let args = default_configure_install_dirs(&flags, Some("--prefix=PREFIX"));
assert!(args.is_empty());
}
#[test]
fn test_makefile_content_has_target_detects_check_and_test() {
let content = r#"
+205
View File
@@ -0,0 +1,205 @@
use clap::{Parser, Subcommand, ValueEnum};
use std::path::PathBuf;
#[derive(Parser)]
#[command(name = "Depot")]
#[command(about = "Depot - Source-based package manager for Linux", long_about = None)]
#[command(version)]
pub struct Cli {
/// Custom root filesystem path
#[arg(long, short = 'r', default_value = "/", global = true)]
pub rootfs: PathBuf,
/// Skip dependency checks
#[arg(long, global = true)]
pub no_deps: bool,
/// Do not export CFLAGS/CXXFLAGS/LDFLAGS to build commands
#[arg(
long,
global = true,
action = clap::ArgAction::Set,
num_args = 0..=1,
default_value_t = false,
default_missing_value = "true"
)]
pub no_flags: bool,
/// Cross-compilation prefix (e.g., x86_64-linux-musl, aarch64-linux-gnu)
#[arg(long, global = true)]
pub cross_prefix: Option<String>,
/// Clean build workspace after successful install/build
#[arg(long, global = true)]
pub clean: bool,
/// Automatically answer yes to prompts and pick the default provider choice
#[arg(long, short = 'y', global = true)]
pub yes: bool,
/// Show what would happen without performing builds/installs
#[arg(long, global = true)]
pub dry_run: bool,
/// Build/install only the lib32 companion package path (skip primary package output)
#[arg(long, global = true)]
pub lib32_only: bool,
#[command(subcommand)]
pub command: Commands,
}
#[derive(Subcommand)]
pub enum Commands {
/// Build and install a package from a spec file
Install {
/// Path to package spec (.toml) or package archive (.tar.zst)
#[arg(value_name = "SPEC_OR_ARCHIVE")]
spec_or_archive: PathBuf,
/// Explicitly specify path to package spec (.toml file)
#[arg(short, long = "spec", visible_alias = "package", alias = "p")]
spec: Option<PathBuf>,
},
/// Remove an installed package
Remove {
/// Package name to remove
package: String,
},
/// Build a package without installing
Build {
/// Path to package spec (.toml file)
#[arg(value_name = "SPEC")]
spec_pos: Option<PathBuf>,
/// Explicitly specify path to package spec (.toml file)
#[arg(short, long = "spec", visible_alias = "package", alias = "p")]
spec: Option<PathBuf>,
/// Install package to rootfs after creating package archive(s)
#[arg(long)]
install: bool,
},
/// Show information about a package
Info {
/// Path to package spec or installed package name
package: String,
},
/// Search configured source and binary repos by package name or provides
Search {
/// Search query
query: String,
/// Search repository file lists (binary repo metadata) by path substring
#[arg(long)]
files: bool,
},
/// Show which installed package owns a filesystem path
Owns {
/// Path to query (absolute or relative to rootfs)
path: PathBuf,
},
/// List installed packages
List,
/// Create a detached minisign signature for a .zst file
Sign {
/// Path to the .zst file to sign
file: PathBuf,
},
/// Repository management
Repo {
#[command(subcommand)]
command: RepoCommands,
},
/// 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)]
pub enum RepoCommands {
/// Create a repository database from a directory of packages
Create {
/// Directory containing .depot.pkg.tar.zst files
#[arg(default_value = ".")]
dir: PathBuf,
},
/// Sync git mirrors configured in /etc/depot.d/mirrors.toml into /usr/src/depot
Sync,
/// Sync source repos configured in /etc/depot.d/repos.toml into /usr/src/depot
Update {
/// Update only one source repo by name
name: Option<String>,
},
/// List configured source and binary repos
List,
/// Add or update a repo entry in /etc/depot.d/repos.toml
Add {
/// Repo name (e.g. vertex)
name: String,
/// Source git URL or binary repo base URL
url: String,
/// Repo kind
#[arg(long, value_enum, default_value_t = RepoKindArg::Source)]
kind: RepoKindArg,
/// Optional source repo subdirectory to index (repeatable)
#[arg(long = "subdir")]
subdirs: Vec<String>,
/// Repo priority (lower = higher priority)
#[arg(long, default_value_t = 0)]
priority: i32,
/// Add repo as disabled
#[arg(long)]
disabled: bool,
/// Binary repo architecture table entry to add/update (defaults to this machine's arch)
#[arg(long)]
arch: Option<String>,
/// Binary repo DB filename/path (relative to repo URL)
#[arg(long = "repo-db", default_value = "repo.db.zst")]
repo_db: String,
/// Allow unsigned repo metadata for this binary repo
#[arg(long)]
allow_unsigned: bool,
},
/// Remove a repo entry from /etc/depot.d/repos.toml
Remove {
/// Repo name
name: String,
/// Repo kind (auto-detect if unique)
#[arg(long)]
kind: Option<RepoKindArg>,
},
/// Enable a repo entry in /etc/depot.d/repos.toml
Enable {
/// Repo name
name: String,
/// Repo kind (auto-detect if unique)
#[arg(long)]
kind: Option<RepoKindArg>,
},
/// Disable a repo entry in /etc/depot.d/repos.toml
Disable {
/// Repo name
name: String,
/// Repo kind (auto-detect if unique)
#[arg(long)]
kind: Option<RepoKindArg>,
},
/// Query binary repo metadata for the package that owns a file path
Owns {
/// Path to query (absolute or relative install path)
path: PathBuf,
},
/// Show status of configured git mirrors
Status,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
pub enum RepoKindArg {
Source,
Binary,
}
+2003
View File
File diff suppressed because it is too large Load Diff
+280 -7
View File
@@ -170,6 +170,7 @@ impl RepoManager {
let mut license = None;
let mut provides = Vec::new();
let mut runtime_dependencies = Vec::new();
let mut optional_dependencies = Vec::new();
let mut archive_files = Vec::new();
for entry in archive.entries()? {
@@ -226,6 +227,17 @@ impl RepoManager {
.map(String::from)
.collect();
}
if let Some(optional_arr) = metadata
.get("dependencies")
.and_then(|v| v.get("optional"))
.and_then(|v| v.as_array())
{
optional_dependencies = optional_arr
.iter()
.filter_map(|v| v.as_str())
.map(String::from)
.collect();
}
continue;
}
@@ -286,6 +298,12 @@ impl RepoManager {
params![package_id, dep],
)?;
}
for dep in optional_dependencies {
conn.execute(
"INSERT INTO dependencies (package_id, kind, name) VALUES (?1, 'optional', ?2)",
params![package_id, dep],
)?;
}
for file_path in archive_files {
conn.execute(
@@ -1304,11 +1322,84 @@ fn verify_binary_package_record_checksums(
Ok(())
}
/// Download a binary package archive and verify it against checksums from the
/// signed repository database metadata.
fn fetch_binary_package_signature(
repo_name: &str,
repo: &crate::config::BinaryRepo,
client: &reqwest::blocking::Client,
sig_url: &str,
sig_path: &Path,
) -> Result<bool> {
let found = fetch_url_to_path(client, sig_url, sig_path)?;
if !found {
if !repo.allow_unsigned {
anyhow::bail!(
"Failed to fetch detached signature for binary package in repo '{}' at {}",
repo_name,
sig_url
);
}
crate::log_warn!(
"Detached package signature missing for binary repo '{}' at {}; allow_unsigned=true so continuing",
repo_name,
sig_url
);
}
Ok(found)
}
fn verify_binary_package_signature(
repo_name: &str,
repo: &crate::config::BinaryRepo,
rootfs: &Path,
pkg_path: &Path,
sig_path: &Path,
) -> Result<()> {
if !sig_path.exists() {
if repo.allow_unsigned {
return Ok(());
}
anyhow::bail!(
"Detached package signature required but missing for {}",
pkg_path.display()
);
}
let trusted_keys = crate::signing::list_trusted_public_keys(rootfs)?;
if trusted_keys.is_empty() {
if repo.allow_unsigned {
crate::log_warn!(
"No trusted minisign public key found; skipping package signature verification for binary repo '{}' because allow_unsigned=true",
repo_name
);
return Ok(());
}
anyhow::bail!(
"No trusted minisign public key found for detached package signature verification in binary repo '{}'",
repo_name
);
}
let verified_key = verify_with_any_trusted_public_key(rootfs, pkg_path, sig_path)
.with_context(|| {
format!(
"Failed to verify detached package signature for {}",
pkg_path.display()
)
})?;
crate::log_info!(
"Verified detached package signature for '{}' using {}",
pkg_path.display(),
verified_key.display()
);
Ok(())
}
/// Download a binary package archive and verify it against detached signatures
/// and checksums from signed repository metadata.
pub fn fetch_binary_package_archive(
repo_name: &str,
repo: &crate::config::BinaryRepo,
rootfs: &Path,
rec: &BinaryRepoPackageRecord,
package_cache_dir: &Path,
) -> Result<PathBuf> {
@@ -1324,7 +1415,15 @@ pub fn fetch_binary_package_archive(
fs::create_dir_all(&cache_dir)
.with_context(|| format!("Failed to create {}", cache_dir.display()))?;
let dest_path = cache_dir.join(&rec.filename);
let dest_sig_path = cache_dir.join(format!("{}.sig", rec.filename));
let tmp_path = cache_dir.join(format!("{}.tmp", rec.filename));
let tmp_sig_path = cache_dir.join(format!("{}.sig.tmp", rec.filename));
let pkg_url = join_repo_url(base_url, &rec.filename)?;
let sig_url = join_repo_url(base_url, &format!("{}.sig", rec.filename))?;
let client = reqwest::blocking::Client::builder()
.build()
.context("Failed to build HTTP client for binary package fetch")?;
if dest_path.exists() {
verify_binary_package_record_checksums(&dest_path, rec).with_context(|| {
@@ -1333,11 +1432,32 @@ pub fn fetch_binary_package_archive(
dest_path.display()
)
})?;
if !dest_sig_path.exists() {
let sig_downloaded =
fetch_binary_package_signature(repo_name, repo, &client, &sig_url, &tmp_sig_path)?;
if sig_downloaded {
fs::rename(&tmp_sig_path, &dest_sig_path).with_context(|| {
format!(
"Failed to move {} to {}",
tmp_sig_path.display(),
dest_sig_path.display()
)
})?;
} else {
let _ = fs::remove_file(&dest_sig_path);
}
}
verify_binary_package_signature(repo_name, repo, rootfs, &dest_path, &dest_sig_path)
.with_context(|| {
format!(
"Cached binary package failed signature verification: {}",
dest_path.display()
)
})?;
crate::log_info!("Using cached binary package: {}", dest_path.display());
return Ok(dest_path);
}
let pkg_url = join_repo_url(base_url, &rec.filename)?;
crate::log_info!("Fetching binary package: {}", pkg_url);
match copy_file_url_to_path(&pkg_url, &tmp_path)? {
@@ -1346,9 +1466,6 @@ pub fn fetch_binary_package_archive(
anyhow::bail!("Failed to fetch {}: local file not found", pkg_url);
}
FileUrlCopyOutcome::NotFileUrl => {
let client = reqwest::blocking::Client::builder()
.build()
.context("Failed to build HTTP client for binary package fetch")?;
let mut resp = client
.get(&pkg_url)
.send()
@@ -1373,6 +1490,9 @@ pub fn fetch_binary_package_archive(
)
})?;
let sig_downloaded =
fetch_binary_package_signature(repo_name, repo, &client, &sig_url, &tmp_sig_path)?;
fs::rename(&tmp_path, &dest_path).with_context(|| {
format!(
"Failed to move {} to {}",
@@ -1380,6 +1500,29 @@ pub fn fetch_binary_package_archive(
dest_path.display()
)
})?;
if sig_downloaded {
fs::rename(&tmp_sig_path, &dest_sig_path).with_context(|| {
format!(
"Failed to move {} to {}",
tmp_sig_path.display(),
dest_sig_path.display()
)
})?;
if let Err(err) =
verify_binary_package_signature(repo_name, repo, rootfs, &dest_path, &dest_sig_path)
{
let _ = fs::remove_file(&dest_path);
let _ = fs::remove_file(&dest_sig_path);
return Err(err).with_context(|| {
format!(
"Downloaded binary package failed signature verification: {}",
rec.filename
)
});
}
} else {
let _ = fs::remove_file(&dest_sig_path);
}
Ok(dest_path)
}
@@ -1599,8 +1742,8 @@ license = "MIT"
provides = ["test-feature"]
[dependencies]
build = []
runtime = []
optional = []
"#;
let mut header = tar::Header::new_gnu();
header.set_path(".metadata.toml").unwrap();
@@ -1798,6 +1941,136 @@ license = ["MIT", "Apache-2.0"]
verify_binary_package_record_checksums(&pkg, &rec).unwrap();
}
fn test_record_for_payload(filename: &str, payload: &[u8]) -> BinaryRepoPackageRecord {
use sha2::{Digest, Sha256, Sha512};
let sha256 = {
let mut h = Sha256::new();
h.update(payload);
format!("{:x}", h.finalize())
};
let sha512 = {
let mut h = Sha512::new();
h.update(payload);
format!("{:x}", h.finalize())
};
BinaryRepoPackageRecord {
repo_name: "repo".into(),
name: "pkg".into(),
version: "1.0".into(),
revision: 1,
filename: filename.to_string(),
size: payload.len() as u64,
sha256,
sha512,
description: None,
provides: Vec::new(),
runtime_dependencies: Vec::new(),
}
}
#[test]
fn test_fetch_binary_package_archive_requires_signature_when_unsigned_disallowed() {
let rootfs = tempfile::tempdir().unwrap();
let repo_dir = tempfile::tempdir().unwrap();
let cache_dir = tempfile::tempdir().unwrap();
let filename = "pkg-1.0-1-x86_64.depot.pkg.tar.zst";
let payload = b"package payload";
std::fs::write(repo_dir.path().join(filename), payload).unwrap();
let rec = test_record_for_payload(filename, payload);
let repo_url = url::Url::from_directory_path(repo_dir.path())
.expect("file URL")
.to_string();
let repo_cfg = crate::config::BinaryRepo {
url: repo_url,
allow_unsigned: false,
..Default::default()
};
let err =
fetch_binary_package_archive("repo", &repo_cfg, rootfs.path(), &rec, cache_dir.path())
.expect_err("missing detached signature should fail");
assert!(err.to_string().to_ascii_lowercase().contains("signature"));
}
#[test]
fn test_fetch_binary_package_archive_verifies_signature_and_checksum() {
let rootfs = tempfile::tempdir().unwrap();
let repo_dir = tempfile::tempdir().unwrap();
let cache_dir = tempfile::tempdir().unwrap();
let trusted_dir = crate::signing::trusted_public_keys_dir(rootfs.path());
std::fs::create_dir_all(&trusted_dir).unwrap();
let keypair = minisign::KeyPair::generate_unencrypted_keypair().unwrap();
std::fs::write(
trusted_dir.join("repo.pub"),
keypair.pk.to_box().unwrap().to_bytes(),
)
.unwrap();
let filename = "pkg-1.0-1-x86_64.depot.pkg.tar.zst";
let payload = b"signed package payload";
let package_path = repo_dir.path().join(filename);
std::fs::write(&package_path, payload).unwrap();
let sig = minisign::sign(
Some(&keypair.pk),
&keypair.sk,
std::fs::File::open(&package_path).unwrap(),
None,
Some("test signature"),
)
.unwrap();
std::fs::write(format!("{}.sig", package_path.display()), sig.to_bytes()).unwrap();
let rec = test_record_for_payload(filename, payload);
let repo_url = url::Url::from_directory_path(repo_dir.path())
.expect("file URL")
.to_string();
let repo_cfg = crate::config::BinaryRepo {
url: repo_url,
allow_unsigned: false,
..Default::default()
};
let fetched =
fetch_binary_package_archive("repo", &repo_cfg, rootfs.path(), &rec, cache_dir.path())
.unwrap();
assert_eq!(std::fs::read(&fetched).unwrap(), payload);
assert!(PathBuf::from(format!("{}.sig", fetched.display())).exists());
}
#[test]
fn test_fetch_binary_package_archive_allows_missing_signature_when_configured() {
let rootfs = tempfile::tempdir().unwrap();
let repo_dir = tempfile::tempdir().unwrap();
let cache_dir = tempfile::tempdir().unwrap();
let filename = "pkg-1.0-1-x86_64.depot.pkg.tar.zst";
let payload = b"unsigned package payload";
std::fs::write(repo_dir.path().join(filename), payload).unwrap();
let rec = test_record_for_payload(filename, payload);
let repo_url = url::Url::from_directory_path(repo_dir.path())
.expect("file URL")
.to_string();
let repo_cfg = crate::config::BinaryRepo {
url: repo_url,
allow_unsigned: true,
..Default::default()
};
let fetched =
fetch_binary_package_archive("repo", &repo_cfg, rootfs.path(), &rec, cache_dir.path())
.unwrap();
assert_eq!(std::fs::read(&fetched).unwrap(), payload);
assert!(!PathBuf::from(format!("{}.sig", fetched.display())).exists());
}
#[test]
fn test_copy_file_url_to_path_supports_file_scheme() {
let tmp = tempfile::tempdir().unwrap();
+84 -17
View File
@@ -9,7 +9,7 @@
//! - `package<=1.2.3` - less than or equal to 1.2.3
use crate::db;
use crate::package::PackageSpec;
use crate::package::{BuildType, PackageSpec};
use crate::ui;
use anyhow::Result;
use std::path::Path;
@@ -139,6 +139,13 @@ fn is_dep_satisfied(
}
}
fn build_type_runs_automatic_tests(spec: &PackageSpec) -> bool {
matches!(
spec.build.build_type,
BuildType::Autotools | BuildType::CMake
)
}
/// 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() {
@@ -173,15 +180,24 @@ pub fn check_build_deps(spec: &PackageSpec, db_path: &Path) -> Result<Vec<String
/// Check if all runtime dependencies are satisfied
pub fn check_runtime_deps(spec: &PackageSpec, db_path: &Path) -> Result<Vec<String>> {
let mut missing = Vec::new();
let local_provides = spec.local_dependency_provides();
if !db_path.exists() {
return Ok(spec.dependencies.runtime.clone());
for dep in &spec.dependencies.runtime {
if !local_provides.contains(dep_name(dep)) {
missing.push(dep.clone());
}
}
return Ok(missing);
}
let installed = db::get_installed_packages(db_path)?;
let provides = db::get_all_provides(db_path)?;
for dep in &spec.dependencies.runtime {
if local_provides.contains(dep_name(dep)) {
continue;
}
if !is_dep_satisfied(dep, &installed, &provides, db_path)? {
missing.push(dep.clone());
}
@@ -244,11 +260,21 @@ pub fn print_dep_status(spec: &PackageSpec, db_path: &Path) -> Result<()> {
"Test dependencies: {}",
spec.dependencies.test.join(", ")
));
if !missing_test.is_empty() {
if !spec.build.flags.skip_tests
&& build_type_runs_automatic_tests(spec)
&& !missing_test.is_empty()
{
ui::warn(format!("Test deps missing: {}", missing_test.join(", ")));
}
}
if !spec.dependencies.optional.is_empty() {
ui::info(format!(
"Optional dependencies: {}",
spec.dependencies.optional.join(", ")
));
}
Ok(())
}
@@ -266,20 +292,6 @@ pub fn require_build_deps(spec: &PackageSpec, db_path: &Path) -> Result<()> {
Ok(())
}
/// Verify all test dependencies are installed, error if not
pub fn require_test_deps(spec: &PackageSpec, db_path: &Path) -> Result<()> {
let missing = check_test_deps(spec, db_path)?;
if !missing.is_empty() {
anyhow::bail!(
"Missing test dependencies: {}\nInstall them first with: depot install <package>",
missing.join(", ")
);
}
Ok(())
}
/// Verify all runtime dependencies are installed, error if not.
pub fn require_runtime_deps(spec: &PackageSpec, db_path: &Path) -> Result<()> {
let missing = check_runtime_deps(spec, db_path)?;
@@ -372,6 +384,7 @@ mod tests {
build: Vec::new(),
runtime: Vec::new(),
test: vec!["bats".into(), "python".into()],
optional: Vec::new(),
},
package_alternatives: Default::default(),
package_dependencies: Default::default(),
@@ -411,6 +424,7 @@ mod tests {
build: Vec::new(),
runtime: vec!["python".into()],
test: Vec::new(),
optional: Vec::new(),
},
package_alternatives: Default::default(),
package_dependencies: Default::default(),
@@ -421,4 +435,57 @@ mod tests {
.expect_err("runtime deps should be required");
assert!(err.to_string().contains("Missing runtime dependencies"));
}
#[test]
fn test_check_runtime_deps_ignores_local_outputs_and_provides() {
let spec = PackageSpec {
package: crate::package::PackageInfo {
name: "foo".into(),
version: "1.0".into(),
revision: 1,
description: "d".into(),
homepage: "h".into(),
license: vec!["MIT".into()],
},
packages: vec![crate::package::PackageInfo {
name: "foo-libs".into(),
version: "1.0".into(),
revision: 1,
description: "d".into(),
homepage: "h".into(),
license: vec!["MIT".into()],
}],
alternatives: Default::default(),
manual_sources: Vec::new(),
source: vec![crate::package::Source {
url: "https://example.test/foo.tar.gz".into(),
sha256: "skip".into(),
extract_dir: "foo".into(),
patches: Vec::new(),
post_extract: Vec::new(),
}],
build: crate::package::Build {
build_type: crate::package::BuildType::Custom,
flags: crate::package::BuildFlags::default(),
},
dependencies: crate::package::Dependencies {
build: Vec::new(),
runtime: vec!["foo-libs".into(), "libfoo".into(), "python".into()],
test: Vec::new(),
optional: Vec::new(),
},
package_alternatives: std::collections::BTreeMap::from([(
"foo-libs".to_string(),
crate::package::Alternatives {
provides: vec!["libfoo".into()],
replaces: Vec::new(),
},
)]),
package_dependencies: Default::default(),
spec_dir: std::path::PathBuf::from("."),
};
let missing = check_runtime_deps(&spec, Path::new("/definitely/not/a/real/db")).unwrap();
assert_eq!(missing, vec!["python".to_string()]);
}
}
+615
View File
@@ -0,0 +1,615 @@
//! Transaction hook loading and execution.
//!
//! Hook files live under `<rootfs>/etc/depot.d/hooks/*.toml` and follow a
//! Starpack-inspired format:
//!
//! ```toml
//! [hook]
//! name = "refresh cache"
//! description = "Refreshes cache after updates"
//!
//! [when]
//! phase = "post"
//! operation = ["install", "update"]
//! packages = ["glibc", "linux*"]
//! paths = ["usr/lib/*"]
//! negation = ["usr/lib/debug/*"]
//!
//! [exec]
//! command = "ldconfig"
//! needs_paths = true
//! ```
//!
//! The legacy section/key names `[Hook]`, `[When]`, and `[Exec]` are accepted.
use crate::fakeroot;
use anyhow::{Context, Result, bail};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus, Stdio};
const TRANSACTION_HOOKS_DIR_REL: &str = "etc/depot.d/hooks";
/// Hook phase within an install/update/remove transaction.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookPhase {
/// Before files are changed.
Pre,
/// After files are changed.
Post,
}
impl HookPhase {
fn parse(raw: &str) -> Option<Self> {
match raw.trim().to_ascii_lowercase().as_str() {
"pre" | "pretransaction" | "pre_transaction" | "pre-transaction" => {
Some(HookPhase::Pre)
}
"post" | "posttransaction" | "post_transaction" | "post-transaction" => {
Some(HookPhase::Post)
}
_ => None,
}
}
fn as_str(self) -> &'static str {
match self {
HookPhase::Pre => "pre",
HookPhase::Post => "post",
}
}
}
/// Transaction operation for hooks.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookOperation {
/// Package install.
Install,
/// Package update/upgrade.
Update,
/// Package removal.
Remove,
}
impl HookOperation {
fn parse(raw: &str) -> Option<Self> {
match raw.trim().to_ascii_lowercase().as_str() {
"install" => Some(HookOperation::Install),
"update" | "upgrade" => Some(HookOperation::Update),
"remove" | "uninstall" => Some(HookOperation::Remove),
_ => None,
}
}
fn as_str(self) -> &'static str {
match self {
HookOperation::Install => "install",
HookOperation::Update => "update",
HookOperation::Remove => "remove",
}
}
}
/// Runtime context for transaction hook matching/execution.
#[derive(Debug, Clone)]
pub struct HookExecutionContext<'a> {
/// Current phase (`pre` or `post`).
pub phase: HookPhase,
/// Current operation (`install`, `update`, `remove`).
pub operation: HookOperation,
/// Package being processed.
pub package: &'a str,
/// Filesystem paths affected by this package action.
pub affected_paths: &'a [String],
}
#[derive(Debug, Clone)]
struct TransactionHook {
file_path: PathBuf,
name: Option<String>,
phase: HookPhase,
operations: Vec<HookOperation>,
packages: Vec<String>,
paths: Vec<String>,
negations: Vec<String>,
command: String,
needs_paths: bool,
}
impl TransactionHook {
fn display_name(&self) -> String {
self.name.clone().unwrap_or_else(|| {
self.file_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("<unnamed-hook>")
.to_string()
})
}
fn matches(&self, ctx: &HookExecutionContext<'_>, normalized_paths: &[String]) -> bool {
if self.phase != ctx.phase {
return false;
}
if !self.operations.is_empty() && !self.operations.contains(&ctx.operation) {
return false;
}
if !self.packages.is_empty() {
let package = ctx.package.trim();
let package_match = self
.packages
.iter()
.any(|pattern| wildcard_match(pattern.trim(), package));
if !package_match {
return false;
}
}
if !self.paths.is_empty() {
let positive_match = normalized_paths.iter().any(|path| {
self.paths.iter().any(|pattern| {
wildcard_match(
&normalize_match_target(pattern),
&normalize_match_target(path),
)
})
});
if !positive_match {
return false;
}
}
if !self.negations.is_empty() {
let negated = normalized_paths.iter().any(|path| {
self.negations.iter().any(|pattern| {
wildcard_match(
&normalize_match_target(pattern),
&normalize_match_target(path),
)
})
});
if negated {
return false;
}
}
true
}
}
/// Return the transaction hook directory for a rootfs.
pub fn transaction_hooks_dir(rootfs: &Path) -> PathBuf {
rootfs.join(TRANSACTION_HOOKS_DIR_REL)
}
/// Load and execute transaction hooks that match `ctx`.
///
/// Returns the number of hooks that were executed.
pub fn run_transaction_hooks(rootfs: &Path, ctx: &HookExecutionContext<'_>) -> Result<usize> {
let hook_dir = transaction_hooks_dir(rootfs);
let hook_files = discover_hook_files(&hook_dir)?;
if hook_files.is_empty() {
return Ok(0);
}
let normalized_paths: Vec<String> = ctx
.affected_paths
.iter()
.map(|p| normalize_match_target(p))
.collect();
let mut executed = 0usize;
for hook_file in hook_files {
let hook = parse_hook_file(&hook_file)?;
if !hook.matches(ctx, &normalized_paths) {
continue;
}
run_hook_command(rootfs, &hook, ctx, &normalized_paths)?;
executed += 1;
}
Ok(executed)
}
fn discover_hook_files(hook_dir: &Path) -> Result<Vec<PathBuf>> {
if !hook_dir.exists() {
return Ok(Vec::new());
}
if !hook_dir.is_dir() {
bail!(
"Transaction hooks path exists but is not a directory: {}",
hook_dir.display()
);
}
let mut files = Vec::new();
for entry in
fs::read_dir(hook_dir).with_context(|| format!("Failed to read {}", hook_dir.display()))?
{
let path = entry
.with_context(|| format!("Failed to list {}", hook_dir.display()))?
.path();
if !path.is_file() {
continue;
}
let is_toml = path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.eq_ignore_ascii_case("toml"))
.unwrap_or(false);
if is_toml {
files.push(path);
}
}
files.sort();
Ok(files)
}
fn parse_hook_file(path: &Path) -> Result<TransactionHook> {
let content =
fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?;
let root: toml::Value =
toml::from_str(&content).with_context(|| format!("Failed to parse {}", path.display()))?;
let root_table = root
.as_table()
.with_context(|| format!("Hook file is not a TOML table: {}", path.display()))?;
let hook_table = select_table(root_table, &["hook", "Hook"]);
let when_table = select_table(root_table, &["when", "When"])
.with_context(|| format!("Missing [when] table in {}", path.display()))?;
let exec_table = select_table(root_table, &["exec", "Exec"])
.with_context(|| format!("Missing [exec] table in {}", path.display()))?;
let phase = get_required_string(when_table, &["phase", "Phase"])
.with_context(|| format!("Missing when.phase in {}", path.display()))
.and_then(|raw| {
HookPhase::parse(&raw)
.with_context(|| format!("Invalid when.phase '{}' in {}", raw, path.display()))
})?;
let operations = get_string_list(when_table, &["operation", "operations", "Operation"])
.with_context(|| format!("Invalid when.operation in {}", path.display()))?
.into_iter()
.map(|raw| {
HookOperation::parse(&raw)
.with_context(|| format!("Invalid operation '{}' in {}", raw, path.display()))
})
.collect::<Result<Vec<_>>>()?;
let packages = get_string_list(when_table, &["package", "packages", "Package", "Packages"])
.with_context(|| format!("Invalid when.packages in {}", path.display()))?;
let paths = get_string_list(when_table, &["path", "paths", "Paths"])
.with_context(|| format!("Invalid when.paths in {}", path.display()))?;
let negations = get_string_list(
when_table,
&[
"negation",
"negations",
"Negation",
"exclude_paths",
"not_paths",
],
)
.with_context(|| format!("Invalid when.negation in {}", path.display()))?;
let command = get_required_string(exec_table, &["command", "Command"])
.with_context(|| format!("Missing exec.command in {}", path.display()))?;
let needs_paths = get_optional_bool(exec_table, &["needs_paths", "NeedsPaths"])
.with_context(|| format!("Invalid exec.needs_paths in {}", path.display()))?
.unwrap_or(false);
Ok(TransactionHook {
file_path: path.to_path_buf(),
name: hook_table.and_then(|t| get_optional_string(t, &["name", "Name"])),
phase,
operations,
packages,
paths,
negations,
command,
needs_paths,
})
}
fn select_table<'a>(
root: &'a toml::map::Map<String, toml::Value>,
keys: &[&str],
) -> Option<&'a toml::map::Map<String, toml::Value>> {
keys.iter()
.find_map(|key| root.get(*key).and_then(toml::Value::as_table))
}
fn get_optional_string(
table: &toml::map::Map<String, toml::Value>,
keys: &[&str],
) -> Option<String> {
keys.iter().find_map(|key| {
table
.get(*key)
.and_then(toml::Value::as_str)
.map(str::to_string)
})
}
fn get_required_string(
table: &toml::map::Map<String, toml::Value>,
keys: &[&str],
) -> Result<String> {
get_optional_string(table, keys)
.filter(|value| !value.trim().is_empty())
.with_context(|| format!("Missing required key '{}'", keys[0]))
}
fn get_string_list(
table: &toml::map::Map<String, toml::Value>,
keys: &[&str],
) -> Result<Vec<String>> {
for key in keys {
let Some(value) = table.get(*key) else {
continue;
};
return match value {
toml::Value::String(s) => Ok(vec![s.to_string()]),
toml::Value::Array(arr) => {
let mut out = Vec::new();
for item in arr {
let value = item
.as_str()
.with_context(|| format!("Expected string elements for key '{}'", key))?;
out.push(value.to_string());
}
Ok(out)
}
_ => bail!("Expected string or string array for key '{}'", key),
};
}
Ok(Vec::new())
}
fn get_optional_bool(
table: &toml::map::Map<String, toml::Value>,
keys: &[&str],
) -> Result<Option<bool>> {
for key in keys {
let Some(value) = table.get(*key) else {
continue;
};
return value
.as_bool()
.map(Some)
.with_context(|| format!("Expected boolean for key '{}'", key));
}
Ok(None)
}
fn run_hook_command(
rootfs: &Path,
hook: &TransactionHook,
ctx: &HookExecutionContext<'_>,
normalized_paths: &[String],
) -> Result<()> {
let hook_name = hook.display_name();
crate::log_info!(
"Running transaction hook '{}' ({}) for {}:{}:{}",
hook_name,
hook.file_path.display(),
ctx.operation.as_str(),
ctx.phase.as_str(),
ctx.package
);
let stdin_payload = if hook.needs_paths {
let mut payload = normalized_paths.join("\n");
if !payload.is_empty() {
payload.push('\n');
}
Some(payload)
} else {
None
};
let mut command = if fakeroot::is_root() && rootfs.join("bin/sh").exists() {
let mut cmd = Command::new("chroot");
cmd.arg(rootfs).arg("/bin/sh").arg("-lc").arg(&hook.command);
cmd
} else {
let mut cmd = Command::new("/bin/sh");
cmd.arg("-lc").arg(&hook.command).current_dir(rootfs);
cmd
};
command
.env("DEPOT_ACTION", ctx.operation.as_str())
.env("DEPOT_PHASE", ctx.phase.as_str())
.env("DEPOT_PACKAGE", ctx.package)
.env("DEPOT_ROOTFS", rootfs)
.env("DEPOT_HOOK_FILE", &hook.file_path)
.env("DEPOT_HOOK_NAME", &hook_name);
let status = run_command_with_optional_stdin(&mut command, stdin_payload.as_deref())
.with_context(|| {
format!(
"Failed to execute transaction hook '{}' ({})",
hook_name,
hook.file_path.display()
)
})?;
if !status.success() {
bail!(
"Transaction hook '{}' failed with status {}",
hook_name,
status
);
}
Ok(())
}
fn run_command_with_optional_stdin(
command: &mut Command,
stdin_payload: Option<&str>,
) -> Result<ExitStatus> {
if let Some(payload) = stdin_payload {
command.stdin(Stdio::piped());
let mut child = command.spawn()?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(payload.as_bytes())?;
}
Ok(child.wait()?)
} else {
Ok(command.status()?)
}
}
fn normalize_match_target(raw: &str) -> String {
raw.trim()
.trim_start_matches("./")
.trim_start_matches('/')
.to_string()
}
fn wildcard_match(pattern: &str, text: &str) -> bool {
let p = pattern.as_bytes();
let t = text.as_bytes();
let mut dp = vec![vec![false; t.len() + 1]; p.len() + 1];
dp[0][0] = true;
for i in 1..=p.len() {
if p[i - 1] == b'*' {
dp[i][0] = dp[i - 1][0];
}
}
for i in 1..=p.len() {
for j in 1..=t.len() {
dp[i][j] = match p[i - 1] {
b'*' => dp[i - 1][j] || dp[i][j - 1],
b'?' => dp[i - 1][j - 1],
ch => dp[i - 1][j - 1] && ch == t[j - 1],
};
}
}
dp[p.len()][t.len()]
}
#[cfg(test)]
mod tests {
use super::*;
fn write_hook(rootfs: &Path, name: &str, content: &str) {
let dir = transaction_hooks_dir(rootfs);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(name), content).unwrap();
}
#[test]
fn wildcard_match_supports_star_and_question() {
assert!(wildcard_match("usr/lib/*", "usr/lib/libc.so"));
assert!(wildcard_match("lib??", "lib32"));
assert!(!wildcard_match("usr/bin/*", "usr/lib/libc.so"));
}
#[test]
fn parse_hook_accepts_starpack_style_sections() {
let tmp = tempfile::tempdir().unwrap();
write_hook(
tmp.path(),
"demo.toml",
r#"
[Hook]
Name = "demo"
[When]
Phase = "PreTransaction"
Operation = "Install"
Package = "foo"
[Exec]
Command = "true"
NeedsPaths = false
"#,
);
let path = transaction_hooks_dir(tmp.path()).join("demo.toml");
let hook = parse_hook_file(&path).unwrap();
assert_eq!(hook.name.as_deref(), Some("demo"));
assert_eq!(hook.phase, HookPhase::Pre);
assert_eq!(hook.operations, vec![HookOperation::Install]);
assert_eq!(hook.packages, vec!["foo".to_string()]);
}
#[test]
fn run_transaction_hooks_executes_matching_hook() {
let tmp = tempfile::tempdir().unwrap();
write_hook(
tmp.path(),
"record.toml",
r#"
[hook]
name = "record"
[when]
phase = "pre"
operation = ["install"]
packages = ["foo*"]
paths = ["usr/bin/*"]
[exec]
command = "printf '%s:%s:%s' \"$DEPOT_ACTION\" \"$DEPOT_PHASE\" \"$DEPOT_PACKAGE\" > \"$DEPOT_ROOTFS/hook.out\""
needs_paths = true
"#,
);
let affected = vec!["usr/bin/foo".to_string()];
let ctx = HookExecutionContext {
phase: HookPhase::Pre,
operation: HookOperation::Install,
package: "foo",
affected_paths: &affected,
};
let ran = run_transaction_hooks(tmp.path(), &ctx).unwrap();
assert_eq!(ran, 1);
let out = std::fs::read_to_string(tmp.path().join("hook.out")).unwrap();
assert_eq!(out, "install:pre:foo");
}
#[test]
fn run_transaction_hooks_respects_negation_and_filters_out() {
let tmp = tempfile::tempdir().unwrap();
write_hook(
tmp.path(),
"skip.toml",
r#"
[hook]
name = "skip"
[when]
phase = "pre"
operation = "remove"
paths = ["usr/lib/*"]
negation = ["usr/lib/debug/*"]
[exec]
command = "touch \"$DEPOT_ROOTFS/should_not_exist\""
"#,
);
let affected = vec!["usr/lib/debug/foo".to_string()];
let ctx = HookExecutionContext {
phase: HookPhase::Pre,
operation: HookOperation::Remove,
package: "foo",
affected_paths: &affected,
};
let ran = run_transaction_hooks(tmp.path(), &ctx).unwrap();
assert_eq!(ran, 0);
assert!(!tmp.path().join("should_not_exist").exists());
}
}
+1
View File
@@ -1,3 +1,4 @@
//! Installation helpers.
pub mod hooks;
pub mod scripts;
+6 -2134
View File
File diff suppressed because it is too large Load Diff
+36 -1
View File
@@ -640,6 +640,10 @@ pub fn create_interactive() -> Result<PackageSpec> {
"Test dependency",
"Package needed only for running package test suites",
)?;
let optional_deps = prompt_repeating_list(
"Optional dependency",
"Package that enables optional runtime functionality",
)?;
Ok(PackageSpec {
package: PackageInfo {
@@ -659,6 +663,7 @@ pub fn create_interactive() -> Result<PackageSpec> {
build: build_deps,
runtime: runtime_deps,
test: test_deps,
optional: optional_deps,
},
package_alternatives: Default::default(),
package_dependencies: Default::default(),
@@ -875,6 +880,12 @@ pub fn spec_to_minimal_toml(spec: &PackageSpec) -> anyhow::Result<String> {
if spec.build.flags.no_strip != defaults.no_strip {
flags_tbl.insert("no_strip".into(), Value::Boolean(spec.build.flags.no_strip));
}
if spec.build.flags.no_delete_static != defaults.no_delete_static {
flags_tbl.insert(
"no_delete_static".into(),
Value::Boolean(spec.build.flags.no_delete_static),
);
}
if spec.build.flags.no_compress_man != defaults.no_compress_man {
flags_tbl.insert(
"no_compress_man".into(),
@@ -1221,6 +1232,7 @@ pub fn spec_to_minimal_toml(spec: &PackageSpec) -> anyhow::Result<String> {
if !spec.dependencies.build.is_empty()
|| !spec.dependencies.runtime.is_empty()
|| !spec.dependencies.test.is_empty()
|| !spec.dependencies.optional.is_empty()
{
let mut dep_tbl = Table::new();
if !spec.dependencies.build.is_empty() {
@@ -1259,6 +1271,18 @@ pub fn spec_to_minimal_toml(spec: &PackageSpec) -> anyhow::Result<String> {
),
);
}
if !spec.dependencies.optional.is_empty() {
dep_tbl.insert(
"optional".into(),
Value::Array(
spec.dependencies
.optional
.iter()
.map(|s| Value::String(s.clone()))
.collect(),
),
);
}
root.insert("dependencies".into(), Value::Table(dep_tbl));
}
@@ -1412,6 +1436,7 @@ mod tests {
flags.keep = vec!["etc/locale.gen".into()];
flags.no_flags = true;
flags.no_strip = true;
flags.no_delete_static = true;
flags.no_compress_man = true;
flags.skip_tests = true;
flags.make_vars = vec!["V=1".into()];
@@ -1467,6 +1492,7 @@ mod tests {
assert!(toml.contains("\"etc/locale.gen\""));
assert!(toml.contains("no_flags = true"));
assert!(toml.contains("no_strip = true"));
assert!(toml.contains("no_delete_static = true"));
assert!(toml.contains("no_compress_man = true"));
assert!(toml.contains("skip_tests = true"));
assert!(toml.contains("make_vars = ["));
@@ -1515,7 +1541,7 @@ mod tests {
}
#[test]
fn spec_to_minimal_toml_includes_test_dependencies() {
fn spec_to_minimal_toml_includes_test_and_optional_dependencies() {
let spec = PackageSpec {
package: PackageInfo {
name: "foo".into(),
@@ -1543,6 +1569,7 @@ mod tests {
build: vec![],
runtime: vec![],
test: vec!["python".into(), "bats".into()],
optional: vec!["gtk-doc".into()],
},
package_alternatives: Default::default(),
package_dependencies: Default::default(),
@@ -1559,6 +1586,13 @@ mod tests {
assert_eq!(test_deps.len(), 2);
assert_eq!(test_deps[0].as_str(), Some("python"));
assert_eq!(test_deps[1].as_str(), Some("bats"));
let optional_deps = val
.get("dependencies")
.and_then(|d| d.get("optional"))
.and_then(|t| t.as_array())
.expect("expected dependencies.optional array");
assert_eq!(optional_deps.len(), 1);
assert_eq!(optional_deps[0].as_str(), Some("gtk-doc"));
}
#[test]
@@ -1584,6 +1618,7 @@ mod tests {
build: Vec::new(),
runtime: vec!["foo".into(), "bar".into()],
test: Vec::new(),
optional: Vec::new(),
},
package_alternatives: Default::default(),
package_dependencies: Default::default(),
+10 -20
View File
@@ -159,20 +159,9 @@ impl Packager {
),
);
// Add dependencies if useful for repo indexing
let mut build_deps = toml::map::Map::new();
build_deps.insert(
"build".to_string(),
toml::Value::Array(
self.spec
.dependencies
.build
.iter()
.map(|s| toml::Value::String(s.clone()))
.collect(),
),
);
build_deps.insert(
// Add install-relevant dependency kinds for repo/runtime consumers.
let mut deps = toml::map::Map::new();
deps.insert(
"runtime".to_string(),
toml::Value::Array(
self.spec
@@ -183,18 +172,18 @@ impl Packager {
.collect(),
),
);
build_deps.insert(
"test".to_string(),
deps.insert(
"optional".to_string(),
toml::Value::Array(
self.spec
.dependencies
.test
.optional
.iter()
.map(|s| toml::Value::String(s.clone()))
.collect(),
),
);
map.insert("dependencies".to_string(), toml::Value::Table(build_deps));
map.insert("dependencies".to_string(), toml::Value::Table(deps));
let toml_str = toml::to_string(&toml::Value::Table(map))
.context("Failed to serialize metadata to TOML")?;
@@ -357,9 +346,10 @@ mod tests {
assert_eq!(val.get("license").and_then(|v| v.as_str()), Some("MIT"));
let deps = val.get("dependencies").unwrap();
assert!(deps.get("build").unwrap().as_array().unwrap().is_empty());
assert!(deps.get("runtime").unwrap().as_array().unwrap().is_empty());
assert!(deps.get("test").unwrap().as_array().unwrap().is_empty());
assert!(deps.get("optional").unwrap().as_array().unwrap().is_empty());
assert!(deps.get("build").is_none());
assert!(deps.get("test").is_none());
}
#[test]
+54 -6
View File
@@ -5,7 +5,7 @@ use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize;
use serde::Serializer;
use std::collections::BTreeMap;
use std::collections::{BTreeMap, HashSet};
use std::fmt;
use std::fs;
use std::path::Path;
@@ -192,6 +192,22 @@ impl PackageSpec {
.unwrap_or_else(|| self.dependencies.clone())
}
/// Return all package names/provided features produced by this spec.
///
/// This includes all output package names and per-output `provides` entries.
pub fn local_dependency_provides(&self) -> HashSet<String> {
let mut names = HashSet::new();
for output in self.outputs() {
let output_name = output.name.clone();
names.insert(output_name.clone());
let alternatives = self.alternatives_for_output(&output_name);
for provided in alternatives.provides {
names.insert(provided);
}
}
names
}
/// Return alternatives/provides for a specific output package name.
///
/// If no per-output override exists, returns the top-level alternatives.
@@ -481,6 +497,11 @@ impl PackageSpec {
self.build.flags.no_strip = b;
}
}
"no_delete_static" | "no-delete-static" => {
if let Some(b) = v.as_bool() {
self.build.flags.no_delete_static = b;
}
}
"no_compress_man"
| "no-compress-man"
| "no_compress_manpages"
@@ -997,6 +1018,11 @@ impl PackageSpec {
self.build.flags.no_strip = b;
}
}
"no_delete_static" | "no-delete-static" => {
if let Some(b) = values.last().and_then(|v| v.as_bool()) {
self.build.flags.no_delete_static = b;
}
}
"no_compress_man"
| "no-compress-man"
| "no_compress_manpages"
@@ -1535,6 +1561,7 @@ make_test_dirs = ["tests"]
make_install_dirs = ["lib"]
no_flags = true
no_strip = true
no_delete_static = true
no_compress_man = true
skip_tests = true
keep = ["etc/locale.gen"]
@@ -1572,6 +1599,10 @@ post_configure = ["echo configured"]
"build.flags.no_compress_man".to_string(),
vec![toml::Value::Boolean(false)],
);
config.appends.insert(
"build.flags.no_delete_static".to_string(),
vec![toml::Value::Boolean(false)],
);
config.appends.insert(
"build.flags.passthrough_env".to_string(),
vec![toml::Value::String("CARGO_HOME".to_string())],
@@ -1627,6 +1658,7 @@ post_configure = ["echo configured"]
);
assert!(spec.build.flags.no_flags);
assert!(!spec.build.flags.no_strip);
assert!(!spec.build.flags.no_delete_static);
assert!(!spec.build.flags.no_compress_man);
assert!(
spec.build
@@ -1735,7 +1767,7 @@ no_flags = true
}
#[test]
fn parse_no_strip_and_no_compress_man_from_spec() {
fn parse_no_strip_no_delete_static_and_no_compress_man_from_spec() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("pkg.toml");
@@ -1759,6 +1791,7 @@ type = "custom"
[build.flags]
no_strip = true
"no-delete-static" = true
no-compress-man = true
"#,
)
@@ -1766,6 +1799,7 @@ no-compress-man = true
let spec = PackageSpec::from_file(&path).unwrap();
assert!(spec.build.flags.no_strip);
assert!(spec.build.flags.no_delete_static);
assert!(spec.build.flags.no_compress_man);
}
@@ -2128,10 +2162,11 @@ extract_dir = "foo"
[build]
type = "autotools"
[dependencies]
build = ["make"]
test = ["python", "bats"]
"#,
[dependencies]
build = ["make"]
test = ["python", "bats"]
optional = ["gtk-doc"]
"#,
)
.unwrap();
@@ -2140,6 +2175,7 @@ test = ["python", "bats"]
spec.dependencies.test,
vec!["python".to_string(), "bats".to_string()]
);
assert_eq!(spec.dependencies.optional, vec!["gtk-doc".to_string()]);
}
#[test]
@@ -2576,6 +2612,14 @@ pub struct BuildFlags {
/// Disable automatic stripping of ELF files during staging.
#[serde(default, alias = "no-strip")]
pub no_strip: bool,
/// Disable automatic deletion of static libraries (`*.a`) during staging.
#[serde(
default,
alias = "no-delete-static",
alias = "no_remove_static",
alias = "no-remove-static"
)]
pub no_delete_static: bool,
/// Disable automatic zstd compression of man pages during staging.
#[serde(
default,
@@ -2814,6 +2858,7 @@ impl Default for BuildFlags {
keep: Vec::new(),
no_flags: false,
no_strip: false,
no_delete_static: false,
no_compress_man: false,
skip_tests: false,
build_32: false,
@@ -2981,4 +3026,7 @@ pub struct Dependencies {
/// Dependencies required to run package test suites.
#[serde(default)]
pub test: Vec<String>,
/// Optional runtime integrations that enhance functionality when installed.
#[serde(default)]
pub optional: Vec<String>,
}
+91 -6
View File
@@ -8,7 +8,7 @@ use crate::config::Config;
use crate::db;
use crate::deps;
use crate::index::PackageIndex;
use crate::package::{BuildType, PackageSpec};
use crate::package::PackageSpec;
use crate::ui;
use anyhow::{Context, Result};
use petgraph::algo::toposort;
@@ -601,16 +601,14 @@ impl<'a> Resolver<'a> {
fn source_deps_for_install(spec: &PackageSpec) -> Vec<String> {
let mut deps_all = Vec::new();
let local_provides = spec.local_dependency_provides();
if !spec.is_metapackage() {
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 {
if !local_provides.contains(deps::dep_name(dep)) {
push_unique(&mut deps_all, dep.clone());
}
}
@@ -618,7 +616,9 @@ fn source_deps_for_install(spec: &PackageSpec) -> Vec<String> {
for out in spec.outputs() {
let deps = spec.dependencies_for_output(&out.name);
for dep in deps.runtime {
push_unique(&mut deps_all, dep);
if !local_provides.contains(deps::dep_name(&dep)) {
push_unique(&mut deps_all, dep);
}
}
}
deps_all
@@ -710,3 +710,88 @@ pub(crate) fn build_dependency_install_plan(
) -> Result<ExecutionPlan> {
Resolver::new(config, rootfs, opts).plan_for_deps(deps_to_install)
}
#[cfg(test)]
mod tests {
use super::source_deps_for_install;
use crate::package::{
Alternatives, Build, BuildFlags, BuildType, Dependencies, PackageInfo, PackageSpec, Source,
};
use std::collections::BTreeMap;
use std::path::PathBuf;
fn mk_spec() -> PackageSpec {
PackageSpec {
package: PackageInfo {
name: "foo".into(),
version: "1.0".into(),
revision: 1,
description: "d".into(),
homepage: "h".into(),
license: vec!["MIT".into()],
},
packages: vec![PackageInfo {
name: "foo-libs".into(),
version: "1.0".into(),
revision: 1,
description: "d".into(),
homepage: "h".into(),
license: vec!["MIT".into()],
}],
alternatives: Alternatives::default(),
manual_sources: Vec::new(),
source: vec![Source {
url: "https://example.test/foo.tar.gz".into(),
sha256: "skip".into(),
extract_dir: "foo".into(),
patches: Vec::new(),
post_extract: Vec::new(),
}],
build: Build {
build_type: BuildType::Autotools,
flags: BuildFlags::default(),
},
dependencies: Dependencies {
build: vec!["make".into()],
runtime: vec!["foo-libs".into(), "zlib".into()],
test: vec!["bats".into()],
optional: vec!["docs-viewer".into()],
},
package_alternatives: BTreeMap::from([(
"foo-libs".into(),
Alternatives {
provides: vec!["libfoo".into()],
replaces: Vec::new(),
},
)]),
package_dependencies: BTreeMap::from([(
"foo".into(),
Dependencies {
build: Vec::new(),
runtime: vec!["foo-libs".into(), "libfoo".into(), "openssl".into()],
test: Vec::new(),
optional: Vec::new(),
},
)]),
spec_dir: PathBuf::from("."),
}
}
#[test]
fn source_deps_for_install_excludes_local_runtime_outputs_and_provides() {
let spec = mk_spec();
let deps = source_deps_for_install(&spec);
assert!(deps.contains(&"make".to_string()));
assert!(deps.contains(&"zlib".to_string()));
assert!(deps.contains(&"openssl".to_string()));
assert!(!deps.contains(&"foo-libs".to_string()));
assert!(!deps.contains(&"libfoo".to_string()));
}
#[test]
fn source_deps_for_install_does_not_include_test_deps() {
let spec = mk_spec();
let deps = source_deps_for_install(&spec);
assert!(!deps.contains(&"bats".to_string()));
}
}
+101 -8
View File
@@ -2,6 +2,7 @@
use crate::package::{PackageSpec, Source};
use anyhow::{Context, Result, bail};
use filetime::FileTime;
use flate2::read::GzDecoder;
use std::fs::{self, File};
use std::io::{Cursor, Read, Write};
@@ -605,20 +606,61 @@ fn move_dir_contents(src: &Path, dest: &Path) -> Result<()> {
let entry = entry?;
let src_path = entry.path();
let dest_path = dest.join(entry.file_name());
let file_type = entry.file_type()?;
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)?;
}
copy_entry_fallback(&src_path, &dest_path, file_type)?;
}
}
Ok(())
}
fn copy_entry_fallback(src_path: &Path, dest_path: &Path, file_type: fs::FileType) -> Result<()> {
if file_type.is_dir() {
copy_dir_recursive_local(src_path, dest_path)?;
fs::remove_dir_all(src_path)?;
} else if file_type.is_symlink() {
if let Some(parent) = dest_path.parent() {
fs::create_dir_all(parent)?;
}
let link_target = fs::read_link(src_path)
.with_context(|| format!("Failed to read symlink {}", src_path.display()))?;
unix_fs::symlink(&link_target, dest_path).with_context(|| {
format!(
"Failed to create symlink {} -> {}",
dest_path.display(),
link_target.display()
)
})?;
fs::remove_file(src_path)?;
} else {
copy_file_preserve_metadata(src_path, dest_path)?;
fs::remove_file(src_path)?;
}
Ok(())
}
fn copy_file_preserve_metadata(src: &Path, dst: &Path) -> Result<()> {
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(src, dst)
.with_context(|| format!("Failed to copy {} -> {}", src.display(), dst.display()))?;
let src_meta = fs::metadata(src)
.with_context(|| format!("Failed to read metadata for {}", src.display()))?;
fs::set_permissions(dst, src_meta.permissions())
.with_context(|| format!("Failed to set permissions for {}", dst.display()))?;
let atime = FileTime::from_last_access_time(&src_meta);
let mtime = FileTime::from_last_modification_time(&src_meta);
filetime::set_file_times(dst, atime, mtime)
.with_context(|| format!("Failed to preserve file timestamps for {}", dst.display()))?;
Ok(())
}
fn copy_dir_recursive_local(src: &Path, dst: &Path) -> Result<()> {
for entry in WalkDir::new(src) {
let entry = entry?;
@@ -633,7 +675,7 @@ fn copy_dir_recursive_local(src: &Path, dst: &Path) -> Result<()> {
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(entry.path(), &target)?;
copy_file_preserve_metadata(entry.path(), &target)?;
}
}
Ok(())
@@ -643,6 +685,7 @@ fn copy_dir_recursive_local(src: &Path, dst: &Path) -> Result<()> {
mod tests {
use super::*;
use std::io::Write;
use std::time::{Duration, SystemTime};
use tempfile::tempdir;
#[test]
@@ -690,6 +733,56 @@ mod tests {
assert!(extract_dir.join("usr/bin/hello-deb").exists());
}
#[test]
fn copy_file_preserve_metadata_keeps_mtime() {
let tmp = tempdir().unwrap();
let src = tmp.path().join("src");
let dst = tmp.path().join("dst");
std::fs::write(&src, b"hello").unwrap();
let fixed = SystemTime::UNIX_EPOCH + Duration::from_secs(946684800); // 2000-01-01 UTC
let ts = FileTime::from_system_time(fixed);
filetime::set_file_times(&src, ts, ts).unwrap();
copy_file_preserve_metadata(&src, &dst).unwrap();
let src_meta = std::fs::metadata(&src).unwrap();
let dst_meta = std::fs::metadata(&dst).unwrap();
assert_eq!(
FileTime::from_last_modification_time(&dst_meta),
FileTime::from_last_modification_time(&src_meta)
);
}
#[test]
fn copy_entry_fallback_preserves_symlink_when_target_is_missing() {
let tmp = tempdir().unwrap();
let src_dir = tmp.path().join("src");
let dst_dir = tmp.path().join("dst");
std::fs::create_dir_all(&src_dir).unwrap();
std::fs::create_dir_all(&dst_dir).unwrap();
let src_link = src_dir.join("RELEASE-NOTES");
std::os::unix::fs::symlink("doc/RelNotes/v1.47.3.txt", &src_link).unwrap();
// Simulate target already moved/removed before copying this symlink entry.
let src_target_dir = src_dir.join("doc");
std::fs::create_dir_all(src_target_dir.join("RelNotes")).unwrap();
std::fs::write(src_target_dir.join("RelNotes/v1.47.3.txt"), "notes").unwrap();
std::fs::remove_dir_all(&src_target_dir).unwrap();
let dst_link = dst_dir.join("RELEASE-NOTES");
let file_type = std::fs::symlink_metadata(&src_link).unwrap().file_type();
copy_entry_fallback(&src_link, &dst_link, file_type).unwrap();
assert!(std::fs::symlink_metadata(&src_link).is_err());
let dst_meta = std::fs::symlink_metadata(&dst_link).unwrap();
assert!(dst_meta.file_type().is_symlink());
assert_eq!(
std::fs::read_link(&dst_link).unwrap(),
PathBuf::from("doc/RelNotes/v1.47.3.txt")
);
}
fn write_cpio_newc_one_file(w: &mut Vec<u8>, name: &str, data: &[u8]) {
// magic + 13 fields of 8 hex chars each
fn h8(v: u64) -> String {
+1 -1
View File
@@ -247,7 +247,7 @@ fn verify_file_hash(path: &Path, expected: &str) -> Result<bool> {
}
}
/// Build a blocking reqwest HTTP client using the platform-default TLS backend.
/// Build a blocking reqwest HTTP client using the configured Cargo TLS backend.
/// Any error building the client is returned directly (no fallback).
pub(crate) fn build_blocking_client(
user_agent: &str,
+100 -5
View File
@@ -240,6 +240,28 @@ fn auto_strip_elf_files(destdir: &Path) -> Result<usize> {
Ok(stripped)
}
fn auto_delete_static_archives(destdir: &Path) -> Result<usize> {
let mut removed = 0usize;
for entry in WalkDir::new(destdir).into_iter().filter_map(|e| e.ok()) {
if !entry.file_type().is_file() {
continue;
}
let path = entry.path();
if path.extension().is_none_or(|ext| ext != "a") {
continue;
}
fs::remove_file(path).with_context(|| {
format!(
"Failed to remove static library {} (disable with build.flags.no_delete_static = true)",
path.display()
)
})?;
removed += 1;
}
Ok(removed)
}
fn compress_manpages_zstd(destdir: &Path) -> Result<usize> {
crate::log_info!("Compressing man pages with zstd...");
let mut man_files = Vec::new();
@@ -356,11 +378,11 @@ fn compress_manpages_zstd(destdir: &Path) -> Result<usize> {
Ok(compressed)
}
/// Process staged files - remove .la files, strip binaries, etc.
/// Process staged files - remove .la files/static libs, strip binaries, etc.
pub fn process(destdir: &Path, spec: &PackageSpec) -> Result<()> {
crate::log_info!("Processing staged files...");
let mut removed_count = 0;
let mut removed_la_count = 0;
for entry in WalkDir::new(destdir).into_iter().filter_map(|e| e.ok()) {
let path = entry.path();
@@ -369,12 +391,23 @@ pub fn process(destdir: &Path, spec: &PackageSpec) -> Result<()> {
if path.extension().map(|e| e == "la").unwrap_or(false) {
crate::log_info!(" Removing: {}", path.display());
fs::remove_file(path)?;
removed_count += 1;
removed_la_count += 1;
}
}
if removed_count > 0 {
crate::log_info!("Removed {} .la file(s)", removed_count);
if removed_la_count > 0 {
crate::log_info!("Removed {} .la file(s)", removed_la_count);
}
if spec.build.flags.no_delete_static {
crate::log_info!(
"Skipping static library cleanup: disabled by build.flags.no_delete_static"
);
} else {
let removed_static = auto_delete_static_archives(destdir)?;
if removed_static > 0 {
crate::log_info!("Removed {} static library archive(s)", removed_static);
}
}
if spec.build.flags.no_strip {
@@ -736,6 +769,68 @@ pub fn install_atomic(
#[cfg(test)]
mod tests {
use super::*;
use crate::package::{Build, BuildFlags, BuildType, Dependencies, PackageInfo, PackageSpec};
fn mk_spec_for_stage_processing() -> PackageSpec {
let mut flags = BuildFlags::default();
flags.no_strip = true;
flags.no_compress_man = true;
PackageSpec {
package: PackageInfo {
name: "foo".into(),
version: "1.0".into(),
revision: 1,
description: "d".into(),
homepage: "h".into(),
license: vec!["MIT".into()],
},
packages: Vec::new(),
alternatives: Default::default(),
manual_sources: Vec::new(),
source: Vec::new(),
build: Build {
build_type: BuildType::Custom,
flags,
},
dependencies: Dependencies::default(),
package_alternatives: Default::default(),
package_dependencies: Default::default(),
spec_dir: PathBuf::from("."),
}
}
#[test]
fn process_removes_static_archives_by_default() {
let tmp = tempfile::tempdir().unwrap();
let destdir = tmp.path().join("dest");
std::fs::create_dir_all(destdir.join("usr/lib")).unwrap();
std::fs::write(destdir.join("usr/lib/libfoo.a"), "static").unwrap();
std::fs::write(destdir.join("usr/lib/libfoo.la"), "libtool").unwrap();
std::fs::write(destdir.join("usr/lib/libfoo.so"), "shared").unwrap();
let spec = mk_spec_for_stage_processing();
process(&destdir, &spec).unwrap();
assert!(!destdir.join("usr/lib/libfoo.a").exists());
assert!(!destdir.join("usr/lib/libfoo.la").exists());
assert!(destdir.join("usr/lib/libfoo.so").exists());
}
#[test]
fn process_preserves_static_archives_when_disabled() {
let tmp = tempfile::tempdir().unwrap();
let destdir = tmp.path().join("dest");
std::fs::create_dir_all(destdir.join("usr/lib")).unwrap();
std::fs::write(destdir.join("usr/lib/libfoo.a"), "static").unwrap();
std::fs::write(destdir.join("usr/lib/libfoo.la"), "libtool").unwrap();
let mut spec = mk_spec_for_stage_processing();
spec.build.flags.no_delete_static = true;
process(&destdir, &spec).unwrap();
assert!(destdir.join("usr/lib/libfoo.a").exists());
assert!(!destdir.join("usr/lib/libfoo.la").exists());
}
#[test]
fn add_licenses_copies_common_files() {
+24 -12
View File
@@ -104,12 +104,7 @@ pub fn prompt_yes_no(prompt: &str, default_yes: bool) -> Result<bool> {
let default_hint = if default_yes { "Y/n" } else { "y/N" };
loop {
print!(
"{} {} [{}] ",
label(Stream::Stdout, "?", "35"),
prompt,
default_hint
);
print!("{prompt} [{default_hint}]: ");
io::stdout()
.flush()
.context("Failed to flush prompt to stdout")?;
@@ -123,10 +118,25 @@ pub fn prompt_yes_no(prompt: &str, default_yes: bool) -> Result<bool> {
return Ok(answer);
}
warn("Please answer with 'y' or 'n'.");
warn("Invalid choice. Please answer with 'y' or 'n'.");
}
}
/// Prompt for a package-oriented action with a Starpack-like layout.
pub fn prompt_package_action(action: &str, packages: &[String], default_yes: bool) -> Result<bool> {
if packages.is_empty() {
return prompt_yes_no(
&format!("No packages were selected for {action}. Continue?"),
default_yes,
);
}
println!();
println!("The following packages will be processed for {}:", action);
println!(" {}", packages.join(" "));
prompt_yes_no("Proceed?", default_yes)
}
/// 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() {
@@ -144,18 +154,17 @@ pub fn prompt_select_index(prompt: &str, options: &[String], default_idx: usize)
}
loop {
println!("{} {}", label(Stream::Stdout, "?", "35"), prompt);
println!("{}:", 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"),
"Choose option [1-{}] (Enter = {}): ",
options.len(),
default_idx + 1
);
@@ -176,7 +185,10 @@ pub fn prompt_select_index(prompt: &str, options: &[String], default_idx: usize)
{
return Ok(num - 1);
}
warn("Please enter a valid option number.");
warn(format!(
"Invalid choice. Please enter a number between 1 and {}.",
options.len()
));
}
}