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:
Generated
+115
-689
File diff suppressed because it is too large
Load Diff
+4
-5
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+280
-7
@@ -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
@@ -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()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
//! Installation helpers.
|
||||
|
||||
pub mod hooks;
|
||||
pub mod scripts;
|
||||
|
||||
+6
-2134
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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]
|
||||
|
||||
+50
-2
@@ -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);
|
||||
}
|
||||
|
||||
@@ -2131,6 +2165,7 @@ type = "autotools"
|
||||
[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>,
|
||||
}
|
||||
|
||||
+90
-5
@@ -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,9 +616,11 @@ 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 {
|
||||
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()));
|
||||
}
|
||||
}
|
||||
|
||||
+99
-6
@@ -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,17 +606,58 @@ 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)?;
|
||||
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 {
|
||||
fs::copy(&src_path, &dest_path)?;
|
||||
fs::remove_file(&src_path)?;
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user