feat: add support for package conflicts in alternatives

- Introduced a new `conflicts` field in the `Alternatives` struct to track conflicting packages.
- Updated database schema to include a `conflicts` table and related queries.
- Enhanced package specification handling to include conflicts in TOML serialization and deserialization.
- Modified the interactive package creation to allow input for conflicting packages.
- Updated source repository index creation to handle conflicts and added relevant statistics.
- Adjusted tests to cover new conflicts functionality in package alternatives.
This commit is contained in:
2026-03-10 16:16:20 -05:00
parent d3d885f54b
commit dcbe38ea2b
12 changed files with 889 additions and 68 deletions
+487 -43
View File
@@ -7,7 +7,7 @@ use anyhow::{Context, Result};
use git2::Direction;
use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
use std::cmp::Ordering;
use std::collections::{HashMap, HashSet};
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::fs;
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
@@ -258,6 +258,13 @@ fn package_spec_from_archive_metadata(metadata: &toml::Value) -> package::Packag
.map(String::from)
.collect();
}
if let Some(conflicts) = metadata.get("conflicts").and_then(|v| v.as_array()) {
spec.alternatives.conflicts = conflicts
.iter()
.filter_map(|v| v.as_str())
.map(String::from)
.collect();
}
spec
}
@@ -297,6 +304,7 @@ fn package_spec_from_repo_record(
packages: Vec::new(),
alternatives: package::Alternatives {
provides: record.provides.clone(),
conflicts: record.conflicts.clone(),
replaces: Vec::new(),
},
manual_sources: Vec::new(),
@@ -728,6 +736,265 @@ struct PlannedPackageInstall {
staged: PlannedStagedInstall,
}
#[derive(Debug, Clone)]
struct InstallConflictSubject {
package: String,
provides: Vec<String>,
conflicts: Vec<String>,
}
#[derive(Debug, Clone)]
struct InstalledConflictPackage {
name: String,
provides: Vec<String>,
}
fn install_conflict_subjects_for_output_spec(
spec: &package::PackageSpec,
) -> Vec<InstallConflictSubject> {
spec.outputs()
.into_iter()
.map(|output| {
let alternatives = spec.alternatives_for_output(&output.name);
InstallConflictSubject {
package: output.name,
provides: alternatives.provides,
conflicts: alternatives.conflicts,
}
})
.collect()
}
fn install_conflict_subjects_for_spec(
spec: &package::PackageSpec,
include_primary: bool,
include_lib32: bool,
) -> Vec<InstallConflictSubject> {
let mut subjects = Vec::new();
if include_primary {
subjects.extend(install_conflict_subjects_for_output_spec(spec));
}
if include_lib32 {
subjects.extend(install_conflict_subjects_for_output_spec(
&make_lib32_package_spec(spec),
));
}
subjects
}
fn install_conflict_subject_for_binary_record(
record: &db::repo::BinaryRepoPackageRecord,
) -> InstallConflictSubject {
InstallConflictSubject {
package: record.name.clone(),
provides: record.provides.clone(),
conflicts: record.conflicts.clone(),
}
}
fn matching_conflict_names(
conflicts: &[String],
package_name: &str,
provides: &[String],
) -> Vec<String> {
let mut matches = Vec::new();
for conflict in conflicts {
if conflict == package_name || provides.iter().any(|provided| provided == conflict) {
matches.push(conflict.clone());
}
}
matches.sort();
matches.dedup();
matches
}
fn validate_no_transaction_conflicts(subjects: &[InstallConflictSubject]) -> Result<()> {
let mut violations = BTreeSet::new();
for (idx, left) in subjects.iter().enumerate() {
for right in subjects.iter().skip(idx + 1) {
let left_hits =
matching_conflict_names(&left.conflicts, &right.package, &right.provides);
if !left_hits.is_empty() {
violations.insert(format!(
"{} conflicts with {} via {}",
left.package,
right.package,
left_hits.join(", ")
));
}
let right_hits =
matching_conflict_names(&right.conflicts, &left.package, &left.provides);
if !right_hits.is_empty() {
violations.insert(format!(
"{} conflicts with {} via {}",
right.package,
left.package,
right_hits.join(", ")
));
}
}
}
if violations.is_empty() {
return Ok(());
}
let mut message =
String::from("Cannot install conflicting packages in the same transaction:\n");
for violation in violations {
message.push_str(" ");
message.push_str(&violation);
message.push('\n');
}
anyhow::bail!(message.trim_end().to_string());
}
fn collect_installed_conflict_packages(db_path: &Path) -> Result<Vec<InstalledConflictPackage>> {
let mut installed = Vec::new();
for record in db::list_installed_package_records(db_path)? {
installed.push(InstalledConflictPackage {
provides: db::get_package_provides(db_path, &record.name)?,
name: record.name,
});
}
Ok(installed)
}
fn collect_conflicting_installed_packages(
subjects: &[InstallConflictSubject],
installed: &[InstalledConflictPackage],
) -> Result<BTreeMap<String, BTreeSet<String>>> {
validate_no_transaction_conflicts(subjects)?;
let planned_packages: HashSet<_> = subjects
.iter()
.map(|subject| subject.package.clone())
.collect();
let mut removals: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for subject in subjects {
for installed_pkg in installed {
if installed_pkg.name == subject.package {
continue;
}
let matched = matching_conflict_names(
&subject.conflicts,
&installed_pkg.name,
&installed_pkg.provides,
);
if matched.is_empty() {
continue;
}
if planned_packages.contains(&installed_pkg.name) {
anyhow::bail!(
"Cannot install conflicting packages in the same transaction: {} conflicts with {}",
subject.package,
installed_pkg.name
);
}
removals
.entry(installed_pkg.name.clone())
.or_default()
.insert(subject.package.clone());
}
}
Ok(removals)
}
fn remove_installed_package_with_hooks(
package: &str,
rootfs: &Path,
config: &config::Config,
) -> Result<()> {
let db_path = config.installed_db_path(rootfs);
let affected_paths = db::get_package_files(&db_path, package)?;
install::hooks::run_transaction_hooks(
rootfs,
&install::hooks::HookExecutionContext {
phase: install::hooks::HookPhase::Pre,
operation: install::hooks::HookOperation::Remove,
package,
affected_paths: &affected_paths,
},
)?;
let script_dir = install::scripts::installed_scripts_dir(rootfs, package);
let _ = install::scripts::run_hook_if_present(
&script_dir,
install::scripts::Hook::PreRemove,
rootfs,
package,
)?;
db::remove_package(&db_path, package, rootfs)?;
let post_remove = install::scripts::run_hook_if_present(
&script_dir,
install::scripts::Hook::PostRemove,
rootfs,
package,
);
let cleanup_scripts = install::scripts::remove_installed_scripts(rootfs, package);
post_remove?;
cleanup_scripts?;
install::hooks::run_transaction_hooks(
rootfs,
&install::hooks::HookExecutionContext {
phase: install::hooks::HookPhase::Post,
operation: install::hooks::HookOperation::Remove,
package,
affected_paths: &affected_paths,
},
)?;
ui::success(format!("Successfully removed {}", package));
Ok(())
}
fn resolve_installed_conflicts_for_subjects(
subjects: &[InstallConflictSubject],
rootfs: &Path,
config: &config::Config,
dry_run: bool,
) -> Result<()> {
if subjects.is_empty() {
return Ok(());
}
let db_path = config.installed_db_path(rootfs);
let installed = collect_installed_conflict_packages(&db_path)?;
let removals = collect_conflicting_installed_packages(subjects, &installed)?;
if removals.is_empty() {
return Ok(());
}
let prompt_entries: Vec<String> = removals
.iter()
.map(|(package, conflicted_by)| {
format!(
"{} (conflicts with {})",
package,
conflicted_by.iter().cloned().collect::<Vec<_>>().join(", ")
)
})
.collect();
if dry_run {
ui::info(format!(
"Dry run: would remove conflicting installed package(s): {}",
prompt_entries.join(", ")
));
return Ok(());
}
if !ui::prompt_package_action("conflict removal", &prompt_entries, true)? {
anyhow::bail!("Aborted");
}
for package in removals.keys() {
ui::info(format!("Removing conflicting package: {}", package));
remove_installed_package_with_hooks(package, rootfs, config)?;
}
Ok(())
}
fn plan_staged_install(
pkg_spec: &package::PackageSpec,
destdir: &Path,
@@ -1288,6 +1555,7 @@ struct UpdateCandidate {
candidate_completed_at: Option<i64>,
runtime_dependencies: Vec<String>,
provides: Vec<String>,
conflicts: Vec<String>,
repo_priority: i32,
origin: UpdateOrigin,
}
@@ -1648,6 +1916,7 @@ fn select_update_candidate(
candidate_completed_at: candidate.completed_at,
runtime_dependencies: candidate.spec.dependencies.runtime.clone(),
provides: candidate.spec.alternatives.provides.clone(),
conflicts: candidate.spec.alternatives.conflicts.clone(),
repo_priority: candidate.repo_priority,
origin: UpdateOrigin::Source {
repo_name: candidate.repo_name.clone(),
@@ -1676,6 +1945,7 @@ fn select_update_candidate(
candidate_completed_at: record.completed_at,
runtime_dependencies: record.runtime_dependencies.clone(),
provides: record.provides.clone(),
conflicts: record.conflicts.clone(),
repo_priority: *repo_priority,
origin: UpdateOrigin::Binary {
repo_name: record.repo_name.clone(),
@@ -1840,6 +2110,16 @@ fn run_update_command(
})
.collect();
let conflict_subjects: Vec<_> = updates
.iter()
.map(|candidate| InstallConflictSubject {
package: candidate.package.clone(),
provides: candidate.provides.clone(),
conflicts: candidate.conflicts.clone(),
})
.collect();
validate_no_transaction_conflicts(&conflict_subjects)?;
ui::info(format!("{} package(s) can be updated:", updates.len()));
for target in &targets {
ui::info(format!(" {}", target));
@@ -1862,6 +2142,13 @@ fn run_update_command(
anyhow::bail!("Aborted");
}
resolve_installed_conflicts_for_subjects(
&conflict_subjects,
options.rootfs,
config,
options.dry_run,
)?;
if !missing_deps.is_empty() {
let dep_plan = planner::build_dependency_install_plan(
config,
@@ -2405,6 +2692,27 @@ fn execute_install_plan_with_child_commands(
anyhow::bail!("Aborted");
}
let mut conflict_subjects = Vec::new();
for step in &actionable_steps {
match &step.origin {
planner::PlanOrigin::Source { path, .. } => {
let mut spec = package::PackageSpec::from_file(path)
.with_context(|| format!("Failed to parse spec {}", path.display()))?;
spec.apply_config(config);
conflict_subjects.extend(install_conflict_subjects_for_spec(
&spec,
true,
spec.build.flags.build_32,
));
}
planner::PlanOrigin::Binary { record, .. } => {
conflict_subjects.push(install_conflict_subject_for_binary_record(record));
}
planner::PlanOrigin::Installed => {}
}
}
resolve_installed_conflicts_for_subjects(&conflict_subjects, rootfs, config, options.dry_run)?;
if options.dry_run {
ui::info("Dry run enabled, no install/build actions executed.");
return Ok(());
@@ -2753,6 +3061,17 @@ fn run_direct_archive_install_requests(
staged_dirs.push(staging_dir);
}
let mut conflict_subjects = Vec::new();
for pkg_spec in &pkg_specs {
conflict_subjects.extend(install_conflict_subjects_for_spec(pkg_spec, true, false));
}
resolve_installed_conflicts_for_subjects(
&conflict_subjects,
options.rootfs,
config,
options.dry_run,
)?;
if options.dry_run {
ui::info("Dry run enabled, stopping before install/build work.");
return Ok(false);
@@ -2892,6 +3211,21 @@ fn run_direct_install_request(
));
}
let mut conflict_subjects = install_conflict_subjects_for_spec(
&pkg_spec,
!options.lib32_only,
staging_dir.is_none() && (options.lib32_only || pkg_spec.build.flags.build_32),
);
if staging_dir.is_some() {
conflict_subjects = install_conflict_subjects_for_spec(&pkg_spec, true, false);
}
resolve_installed_conflicts_for_subjects(
&conflict_subjects,
options.rootfs,
config,
options.dry_run,
)?;
if options.dry_run {
ui::info("Dry run enabled, stopping before install/build work.");
return Ok(false);
@@ -3222,48 +3556,11 @@ pub fn run(cli: Cli) -> Result<()> {
let remove_lock_path = locking::lock_path(&config);
let _remove_lock_guard =
locking::try_write(&mut remove_lock, &remove_lock_path, "remove")?;
let db_path = config.db_dir.join("packages.db");
let removal_targets = vec![package.clone()];
if !ui::prompt_package_action("removal", &removal_targets, true)? {
anyhow::bail!("Aborted");
}
let affected_paths = db::get_package_files(&db_path, &package)?;
install::hooks::run_transaction_hooks(
&cli.rootfs,
&install::hooks::HookExecutionContext {
phase: install::hooks::HookPhase::Pre,
operation: install::hooks::HookOperation::Remove,
package: &package,
affected_paths: &affected_paths,
},
)?;
let script_dir = install::scripts::installed_scripts_dir(&cli.rootfs, &package);
let _ = install::scripts::run_hook_if_present(
&script_dir,
install::scripts::Hook::PreRemove,
&cli.rootfs,
&package,
)?;
db::remove_package(&db_path, &package, &cli.rootfs)?;
let post_remove = install::scripts::run_hook_if_present(
&script_dir,
install::scripts::Hook::PostRemove,
&cli.rootfs,
&package,
);
let cleanup_scripts = install::scripts::remove_installed_scripts(&cli.rootfs, &package);
post_remove?;
cleanup_scripts?;
install::hooks::run_transaction_hooks(
&cli.rootfs,
&install::hooks::HookExecutionContext {
phase: install::hooks::HookPhase::Post,
operation: install::hooks::HookOperation::Remove,
package: &package,
affected_paths: &affected_paths,
},
)?;
ui::success(format!("Successfully removed {}", package));
remove_installed_package_with_hooks(&package, &cli.rootfs, &config)?;
}
Commands::Build {
spec_pos,
@@ -3736,11 +4033,13 @@ pub fn run(cli: Cli) -> Result<()> {
stats.index_path.display()
));
ui::info(format!(
"Indexed {} spec(s) from {} TOML file(s): package rows={} provides rows={} ignored_toml={}",
"Indexed {} spec(s) from {} TOML file(s): package rows={} provides rows={} conflicts rows={} dependency rows={} ignored_toml={}",
stats.specs_indexed,
stats.toml_files_scanned,
stats.package_rows,
stats.provides_rows,
stats.conflicts_rows,
stats.dependency_rows,
stats.ignored_toml_files
));
}
@@ -4173,6 +4472,7 @@ mod tests {
homepage: Some("https://example.test".into()),
license: Some("MIT".into()),
provides: vec!["pkg-virtual".into()],
conflicts: Vec::new(),
runtime_dependencies: vec!["glibc".into()],
optional_dependencies: vec!["manpages".into()],
};
@@ -4228,6 +4528,7 @@ mod tests {
fn write_archive(
archive_path: &Path,
package_name: &str,
conflicts: &[&str],
payload_path: &str,
payload: &[u8],
) -> Result<()> {
@@ -4244,8 +4545,20 @@ mod tests {
payload_header.set_cksum();
tar.append(&payload_header, payload)?;
let conflicts_toml = if conflicts.is_empty() {
String::new()
} else {
format!(
"conflicts = [{}]\n",
conflicts
.iter()
.map(|conflict| format!("\"{conflict}\""))
.collect::<Vec<_>>()
.join(", ")
)
};
let metadata = format!(
"name = \"{package_name}\"\nversion = \"1.0\"\nrevision = 1\ndescription = \"test\"\nhomepage = \"https://example.test\"\nlicense = \"MIT\"\n\n[dependencies]\nruntime = []\noptional = []\n"
"name = \"{package_name}\"\nversion = \"1.0\"\nrevision = 1\ndescription = \"test\"\nhomepage = \"https://example.test\"\nlicense = \"MIT\"\n{conflicts_toml}\n[dependencies]\nruntime = []\noptional = []\n"
);
let mut meta_header = tar::Header::new_gnu();
meta_header.set_path(".metadata.toml")?;
@@ -4263,8 +4576,8 @@ mod tests {
let pkg_dir = tempfile::tempdir().context("Failed to create temp package dir")?;
let archive_a = pkg_dir.path().join("alpha-1.0-1-x86_64.depot.pkg.tar.zst");
let archive_b = pkg_dir.path().join("beta-1.0-1-x86_64.depot.pkg.tar.zst");
write_archive(&archive_a, "alpha", "usr/bin/alpha", b"alpha")?;
write_archive(&archive_b, "beta", "usr/bin/beta", b"beta")?;
write_archive(&archive_a, "alpha", &[], "usr/bin/alpha", b"alpha")?;
write_archive(&archive_b, "beta", &[], "usr/bin/beta", b"beta")?;
let mut cfg = config::Config::for_rootfs(rootfs.path());
cfg.build_dir = rootfs.path().join("var/cache/depot/build");
@@ -4301,6 +4614,110 @@ mod tests {
Ok(())
}
#[test]
fn direct_archive_install_rejects_conflicting_archives_in_same_batch() -> Result<()> {
fn write_archive(
archive_path: &Path,
package_name: &str,
conflicts: &[&str],
) -> Result<()> {
let file = fs::File::create(archive_path)
.with_context(|| format!("Failed to create {}", archive_path.display()))?;
let encoder = zstd::stream::write::Encoder::new(file, 3)
.context("Failed to create zstd encoder")?;
let mut tar = tar::Builder::new(encoder);
let payload = package_name.as_bytes();
let mut payload_header = tar::Header::new_gnu();
payload_header.set_path(format!("usr/bin/{package_name}"))?;
payload_header.set_size(payload.len() as u64);
payload_header.set_mode(0o755);
payload_header.set_cksum();
tar.append(&payload_header, payload)?;
let conflicts_toml = if conflicts.is_empty() {
String::new()
} else {
format!(
"conflicts = [{}]\n",
conflicts
.iter()
.map(|conflict| format!("\"{conflict}\""))
.collect::<Vec<_>>()
.join(", ")
)
};
let metadata = format!(
"name = \"{package_name}\"\nversion = \"1.0\"\nrevision = 1\ndescription = \"test\"\nhomepage = \"https://example.test\"\nlicense = \"MIT\"\n{conflicts_toml}\n[dependencies]\nruntime = []\noptional = []\n"
);
let mut meta_header = tar::Header::new_gnu();
meta_header.set_path(".metadata.toml")?;
meta_header.set_size(metadata.len() as u64);
meta_header.set_mode(0o644);
meta_header.set_cksum();
tar.append(&meta_header, metadata.as_bytes())?;
let encoder = tar.into_inner()?;
encoder.finish()?;
Ok(())
}
let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?;
let pkg_dir = tempfile::tempdir().context("Failed to create temp package dir")?;
let archive_a = pkg_dir.path().join("alpha-1.0-1-x86_64.depot.pkg.tar.zst");
let archive_b = pkg_dir.path().join("beta-1.0-1-x86_64.depot.pkg.tar.zst");
write_archive(&archive_a, "alpha", &["beta"])?;
write_archive(&archive_b, "beta", &[])?;
let mut cfg = config::Config::for_rootfs(rootfs.path());
cfg.build_dir = rootfs.path().join("var/cache/depot/build");
cfg.db_dir = rootfs.path().join("var/lib/depot");
let err = run_direct_archive_install_requests(
DirectInstallOptions {
rootfs: rootfs.path(),
no_deps: true,
no_flags: false,
cross_prefix: None,
clean: false,
dry_run: false,
lib32_only: false,
install_test_deps: false,
},
&cfg,
&[archive_a, archive_b],
false,
)
.expect_err("conflicting archives should be rejected");
assert!(
err.to_string()
.contains("Cannot install conflicting packages in the same transaction")
);
Ok(())
}
#[test]
fn collect_conflicting_installed_packages_matches_by_name_and_provide() -> Result<()> {
let removals = collect_conflicting_installed_packages(
&[InstallConflictSubject {
package: "beta".into(),
provides: Vec::new(),
conflicts: vec!["alpha".into(), "editor".into()],
}],
&[InstalledConflictPackage {
name: "alpha".into(),
provides: vec!["editor".into()],
}],
)?;
assert_eq!(
removals.get("alpha"),
Some(&BTreeSet::from(["beta".to_string()]))
);
Ok(())
}
#[test]
#[cfg(unix)]
fn binary_archive_install_preserves_setuid_permissions() -> Result<()> {
@@ -4354,6 +4771,7 @@ mod tests {
homepage: Some("https://example.test".into()),
license: Some("ISC".into()),
provides: Vec::new(),
conflicts: Vec::new(),
runtime_dependencies: Vec::new(),
optional_dependencies: Vec::new(),
};
@@ -4567,6 +4985,7 @@ optional = []
homepage: None,
license: None,
provides: Vec::new(),
conflicts: Vec::new(),
runtime_dependencies: Vec::new(),
optional_dependencies: Vec::new(),
},
@@ -4741,6 +5160,7 @@ optional = []
candidate_completed_at: None,
runtime_dependencies: vec!["glibc".into(), "helper-virtual".into()],
provides: Vec::new(),
conflicts: Vec::new(),
repo_priority: 0,
origin: UpdateOrigin::Source {
repo_name: "source".into(),
@@ -4757,6 +5177,7 @@ optional = []
candidate_completed_at: None,
runtime_dependencies: Vec::new(),
provides: vec!["helper-virtual".into()],
conflicts: Vec::new(),
repo_priority: 0,
origin: UpdateOrigin::Source {
repo_name: "source".into(),
@@ -4773,6 +5194,7 @@ optional = []
candidate_completed_at: None,
runtime_dependencies: vec!["newdep".into()],
provides: Vec::new(),
conflicts: Vec::new(),
repo_priority: 0,
origin: UpdateOrigin::Source {
repo_name: "source".into(),
@@ -4787,6 +5209,28 @@ optional = []
Ok(())
}
#[test]
fn validate_no_transaction_conflicts_rejects_conflicting_updates() {
let err = validate_no_transaction_conflicts(&[
InstallConflictSubject {
package: "alpha".into(),
provides: Vec::new(),
conflicts: vec!["beta".into()],
},
InstallConflictSubject {
package: "beta".into(),
provides: Vec::new(),
conflicts: Vec::new(),
},
])
.expect_err("conflicting update set should be rejected");
assert!(
err.to_string()
.contains("Cannot install conflicting packages in the same transaction")
);
}
#[test]
fn compare_versions_for_updates_handles_semver_and_date_versions() {
assert_eq!(