use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use crate::sysroot;
use crate::types::AlternativeEntry;
const STATE_DIR: &str = "var/lib/zoi/alternatives";
const ETC_ALTERNATIVES_DIR: &str = "etc/alternatives";
const STATE_FILE: &str = "state.json";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Alternative {
pub path: String,
pub priority: i32,
pub package: String,
pub sequence: u64
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AlternativeGroup {
pub name: String,
pub link: String,
pub alternatives: Vec<Alternative>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub link_set: Option<String>
}
impl AlternativeGroup {
pub fn current(&self) -> Option<&Alternative> {
if let Some(chosen) = &self.link_set
&& let Some(found) =
self.alternatives.iter().find(|a| &a.path == chosen)
{
return Some(found);
}
self.best_by_priority()
}
fn best_by_priority(&self) -> Option<&Alternative> {
self.alternatives
.iter()
.max_by(|a, b| {
(a.priority, a.sequence).cmp(&(b.priority, b.sequence))
})
}
pub fn status(&self) -> AlternativeStatus {
let current = self.current();
let mut manual = false;
if let Some(chosen) = &self.link_set
&& self.alternatives.iter().any(|a| &a.path == chosen)
{
manual = true;
}
AlternativeStatus {
name: self.name.clone(),
link: self.link.clone(),
current: current.map(|a| a.path.clone()),
manual,
alternatives: self.alternatives.clone()
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AlternativeStatus {
pub name: String,
pub link: String,
pub current: Option<String>,
pub manual: bool,
pub alternatives: Vec<Alternative>
}
fn root() -> PathBuf {
sysroot::get_sysroot().unwrap_or_else(|| PathBuf::from("/"))
}
fn in_root(root: &Path, absolute: &str) -> PathBuf {
root.join(absolute.trim_start_matches('/'))
}
fn validate_absolute(field: &str, value: &str) -> Result<()> {
if !value.starts_with('/') {
bail!("{field} must be an absolute path, got '{value}'");
}
if value.contains("..") {
bail!("{field} must not contain '..', got '{value}'");
}
Ok(())
}
fn validate_name(name: &str) -> Result<()> {
if name.is_empty() {
bail!("An alternative name must not be empty");
}
if name.contains('/') || name.contains('\0') {
bail!("Invalid alternative name '{name}': must not contain '/'");
}
Ok(())
}
pub fn list() -> Result<Vec<AlternativeGroup>> {
list_in(&root())
}
pub fn list_in(root: &Path) -> Result<Vec<AlternativeGroup>> {
let dir = root.join(STATE_DIR);
let Ok(entries) = fs::read_dir(&dir) else {
return Ok(Vec::new());
};
let mut groups = BTreeMap::new();
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let Ok(group) = read_group(&path) else {
continue;
};
groups.insert(group.name.clone(), group);
}
Ok(groups.into_values().collect())
}
pub fn get(name: &str) -> Result<Option<AlternativeGroup>> {
get_in(&root(), name)
}
pub fn get_in(root: &Path, name: &str) -> Result<Option<AlternativeGroup>> {
validate_name(name)?;
let path = root.join(STATE_DIR).join(name);
if !path.is_dir() {
return Ok(None);
}
Ok(Some(read_group(&path)?))
}
fn read_group(dir: &Path) -> Result<AlternativeGroup> {
let path = dir.join(STATE_FILE);
let content = fs::read_to_string(&path)
.with_context(|| format!("Failed to read {}", path.display()))?;
serde_json::from_str(&content)
.with_context(|| format!("Failed to parse {}", path.display()))
}
fn write_group(root: &Path, group: &AlternativeGroup) -> Result<()> {
let dir = root.join(STATE_DIR).join(&group.name);
fs::create_dir_all(&dir)
.with_context(|| format!("Failed to create {}", dir.display()))?;
let final_path = dir.join(STATE_FILE);
let temp_path = dir.join(format!("{STATE_FILE}.tmp"));
fs::write(&temp_path, serde_json::to_string_pretty(group)?)
.with_context(|| format!("Failed to write {}", temp_path.display()))?;
fs::rename(&temp_path, &final_path).with_context(|| {
format!("Failed to replace {}", final_path.display())
})?;
Ok(())
}
fn next_sequence(group: &AlternativeGroup) -> u64 {
group
.alternatives
.iter()
.map(|a| a.sequence)
.max()
.unwrap_or(0)
+ 1
}
pub fn register(
entries: &[AlternativeEntry],
package: &str
) -> Result<Vec<String>> {
register_in(&root(), entries, package)
}
pub fn register_in(
root: &Path,
entries: &[AlternativeEntry],
package: &str
) -> Result<Vec<String>> {
if entries.is_empty() {
return Ok(Vec::new());
}
let mut changed = Vec::new();
let mut grouped: BTreeMap<&str, Vec<&AlternativeEntry>> = BTreeMap::new();
for entry in entries {
validate_name(&entry.name)?;
validate_absolute("alternatives.path", &entry.path)?;
validate_absolute("alternatives.link", &entry.link)?;
grouped.entry(&entry.name).or_default().push(entry);
}
for (name, group_entries) in grouped {
let Some(first_entry) = group_entries.first() else {
continue;
};
let mut group = get_in(root, name)?.unwrap_or(AlternativeGroup {
name: name.to_string(),
link: first_entry.link.clone(),
alternatives: Vec::new(),
link_set: None
});
if group.link != first_entry.link {
eprintln!(
"Warning: alternative '{name}' is registered with link '{}' \
but '{}' expects '{}'. Keeping '{}'.",
group.link, package, first_entry.link, group.link
);
}
let before = group.current().map(|a| a.path.clone());
for entry in group_entries {
let sequence = next_sequence(&group);
match group.alternatives.iter_mut().find(|a| a.path == entry.path) {
Some(existing) => {
existing.priority = entry.priority;
existing.package = package.to_string();
existing.sequence = sequence;
}
None => group.alternatives.push(Alternative {
path: entry.path.clone(),
priority: entry.priority,
package: package.to_string(),
sequence
})
}
}
let after = group.current().map(|a| a.path.clone());
write_group(root, &group)?;
apply_links(root, &group)?;
if before != after {
changed.push(name.to_string());
}
}
Ok(changed)
}
pub fn deregister(package: &str) -> Result<Vec<String>> {
deregister_in(&root(), package)
}
pub fn deregister_in(root: &Path, package: &str) -> Result<Vec<String>> {
let groups = list_in(root)?;
let mut changed = Vec::new();
for mut group in groups {
if !group.alternatives.iter().any(|a| a.package == package) {
continue;
}
let before = group.current().map(|a| a.path.clone());
group.alternatives.retain(|a| a.package != package);
if group.link_set.as_ref().is_some_and(|chosen| {
group.alternatives.iter().all(|a| &a.path != chosen)
}) {
group.link_set = None;
}
let after = group.current().map(|a| a.path.clone());
if group.alternatives.is_empty() {
remove_group(root, &group)?;
} else {
write_group(root, &group)?;
apply_links(root, &group)?;
}
if before != after {
changed.push(group.name);
}
}
Ok(changed)
}
fn remove_group(root: &Path, group: &AlternativeGroup) -> Result<()> {
let _ = fs::remove_file(in_root(root, &group.link));
let _ = fs::remove_file(in_root(root, &etc_path(&group.name)?));
let _ = fs::remove_dir_all(root.join(STATE_DIR).join(&group.name));
Ok(())
}
pub fn set(name: &str, path: &str) -> Result<()> {
set_in(&root(), name, path)
}
pub fn set_in(root: &Path, name: &str, path: &str) -> Result<()> {
let Some(mut group) = get_in(root, name)? else {
bail!("No such alternative: '{name}'. Run 'zoi alt list' to see them.");
};
if !group.alternatives.iter().any(|a| a.path == path) {
bail!(
"'{path}' is not a registered alternative for '{name}'. \
Available: {}",
group
.alternatives
.iter()
.map(|a| a.path.clone())
.collect::<Vec<_>>()
.join(", ")
);
}
group.link_set = Some(path.to_string());
write_group(root, &group)?;
apply_links(root, &group)?;
Ok(())
}
pub fn auto(name: &str) -> Result<Option<String>> {
auto_in(&root(), name)
}
pub fn auto_in(root: &Path, name: &str) -> Result<Option<String>> {
let Some(mut group) = get_in(root, name)? else {
bail!("No such alternative: '{name}'.");
};
group.link_set = None;
let selected = group.current().map(|a| a.path.clone());
write_group(root, &group)?;
apply_links(root, &group)?;
Ok(selected)
}
pub fn remove_implementation(name: &str, path: &str) -> Result<()> {
remove_implementation_in(&root(), name, path)
}
pub fn remove_implementation_in(
root: &Path,
name: &str,
path: &str
) -> Result<()> {
let Some(mut group) = get_in(root, name)? else {
bail!("No such alternative: '{name}'.");
};
let before = group.alternatives.len();
group.alternatives.retain(|a| a.path != path);
if group.alternatives.len() == before {
bail!("'{path}' is not registered as an alternative for '{name}'.");
}
if group.link_set.as_deref() == Some(path) {
group.link_set = None;
}
if group.alternatives.is_empty() {
remove_group(root, &group)?;
} else {
write_group(root, &group)?;
apply_links(root, &group)?;
}
Ok(())
}
fn etc_path(name: &str) -> Result<String> {
validate_name(name)?;
Ok(format!("/{ETC_ALTERNATIVES_DIR}/{name}"))
}
fn apply_links(root: &Path, group: &AlternativeGroup) -> Result<()> {
let etc_dir = root.join(ETC_ALTERNATIVES_DIR);
fs::create_dir_all(&etc_dir)
.with_context(|| format!("Failed to create {}", etc_dir.display()))?;
let etc_link = etc_dir.join(&group.name);
if let Some(selected) = group.current() {
symlink_to(root, &selected.path, &etc_link)?;
let canonical = in_root(root, &group.link);
symlink_to(root, &etc_path(&group.name)?, &canonical)?;
} else {
let _ = fs::remove_file(&etc_link);
let _ = fs::remove_file(in_root(root, &group.link));
}
Ok(())
}
fn symlink_to(root: &Path, target: &str, link: &Path) -> Result<()> {
let effective = if root == Path::new("/") {
target.to_string()
} else {
in_root(root, target).to_string_lossy().to_string()
};
if let Ok(existing) = fs::read_link(link)
&& existing.to_string_lossy() == effective
{
return Ok(());
}
if let Some(parent) = link.parent() {
fs::create_dir_all(parent).with_context(|| {
format!("Failed to create {}", parent.display())
})?;
}
match fs::symlink_metadata(link) {
Ok(meta) if meta.is_dir() => {
fs::remove_dir_all(link).with_context(|| {
format!("Failed to replace directory {}", link.display())
})?;
}
Ok(_) => {
fs::remove_file(link).with_context(|| {
format!("Failed to remove {}", link.display())
})?;
}
Err(_) => {}
}
#[cfg(unix)]
let created = std::os::unix::fs::symlink(&effective, link);
#[cfg(windows)]
let created = if Path::new(&effective).is_dir() {
std::os::windows::fs::symlink_dir(&effective, link)
} else {
std::os::windows::fs::symlink_file(&effective, link)
};
created.with_context(|| {
format!("Failed to create symlink {} -> {effective}", link.display())
})?;
Ok(())
}
pub fn status() -> Result<Vec<AlternativeStatus>> {
Ok(list()?.iter().map(AlternativeGroup::status).collect())
}
pub fn reconcile(touched: &[String]) -> Result<Vec<String>> {
reconcile_in(&root(), touched)
}
pub fn reconcile_in(root: &Path, touched: &[String]) -> Result<Vec<String>> {
if touched.is_empty() {
return Ok(Vec::new());
}
let mut names: Vec<String> = touched.to_vec();
names.sort();
names.dedup();
let mut changed = Vec::new();
for name in &names {
let manifest = find_installed(root, name);
match manifest {
Some(manifest) => {
let entries = manifest.alternatives.unwrap_or_default();
let stale = stale_groups(root, name, &entries)?;
for group in stale {
if deregister_group(root, &group, name)? {
changed.push(group);
}
}
if entries.is_empty() {
continue;
}
changed.extend(register_in(root, &entries, name)?);
}
None => {
changed.extend(deregister_in(root, name)?);
}
}
}
changed.sort();
changed.dedup();
Ok(changed)
}
fn store_dirs(root: &Path) -> Vec<PathBuf> {
let mut dirs = Vec::new();
dirs.push(join_under(root, Path::new("/var/lib/zoi/pkgs/store")));
if root == Path::new("/") {
if let Ok(user_store) =
crate::utils::get_store_base_dir(crate::types::Scope::User)
{
dirs.push(user_store);
}
} else {
dirs.push(root.join(".local/share/zoi/pkgs/store"));
}
dirs
}
fn join_under(root: &Path, absolute: &Path) -> PathBuf {
if root == Path::new("/") {
return absolute.to_path_buf();
}
root.join(absolute.strip_prefix("/").unwrap_or(absolute))
}
fn find_installed(
root: &Path,
name: &str
) -> Option<crate::types::InstallManifest> {
let suffix = format!("-{name}");
for store in store_dirs(root) {
let Ok(entries) = fs::read_dir(&store) else {
continue;
};
for entry in entries.flatten() {
let pkg_dir = entry.path();
if !pkg_dir.is_dir() {
continue;
}
let dir_name = pkg_dir.file_name().and_then(|n| n.to_str());
let Some(dir_name) = dir_name else {
continue;
};
if !dir_name.ends_with(&suffix) {
continue;
}
let Ok(versions) = fs::read_dir(&pkg_dir) else {
continue;
};
for version in versions.flatten() {
let manifest_path = version.path().join("manifest.json");
let Ok(content) = fs::read_to_string(&manifest_path) else {
continue;
};
if let Ok(manifest) = serde_json::from_str::<
crate::types::InstallManifest
>(&content)
{
return Some(manifest);
}
}
}
}
None
}
fn stale_groups(
root: &Path,
package: &str,
declared: &[AlternativeEntry]
) -> Result<Vec<String>> {
let groups = list_in(root)?;
let mut stale = Vec::new();
for group in groups {
let owned_paths: Vec<&str> = group
.alternatives
.iter()
.filter(|a| a.package == package)
.map(|a| a.path.as_str())
.collect();
if owned_paths.is_empty() {
continue;
}
let no_longer_declared = owned_paths.iter().any(|path| {
!declared
.iter()
.any(|entry| entry.name == group.name && entry.path == **path)
});
if no_longer_declared {
stale.push(group.name);
}
}
Ok(stale)
}
fn deregister_group(root: &Path, name: &str, package: &str) -> Result<bool> {
let Some(mut group) = get_in(root, name)? else {
return Ok(false);
};
let before = group.current().map(|a| a.path.clone());
group.alternatives.retain(|a| a.package != package);
if group.link_set.as_ref().is_some_and(|chosen| {
group.alternatives.iter().all(|a| &a.path != chosen)
}) {
group.link_set = None;
}
let after = group.current().map(|a| a.path.clone());
if group.alternatives.is_empty() {
remove_group(root, &group)?;
} else {
write_group(root, &group)?;
apply_links(root, &group)?;
}
Ok(before != after)
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_root() -> tempfile::TempDir {
tempfile::tempdir().expect("temp dir")
}
fn entry(
name: &str,
path: &str,
link: &str,
priority: i32
) -> AlternativeEntry {
AlternativeEntry {
name: name.to_string(),
path: path.to_string(),
link: link.to_string(),
priority
}
}
fn make_binary(root: &Path, path: &str) {
let full = in_root(root, path);
fs::create_dir_all(full.parent().expect("parent")).expect("mkdir");
fs::write(&full, "#!/bin/true\n").expect("write");
}
fn read_link(root: &Path, path: &str) -> String {
fs::read_link(in_root(root, path))
.expect("link")
.to_string_lossy()
.to_string()
}
fn current_path_in(root: &Path, name: &str) -> String {
let group = get_in(root, name)
.expect("group lookup succeeds")
.unwrap_or_else(|| panic!("alternative '{name}' should exist"));
group
.current()
.unwrap_or_else(|| {
panic!("alternative '{name}' should have a selection")
})
.path
.clone()
}
#[test]
fn registration_links_the_canonical_path() {
let root = temp_root();
make_binary(root.path(), "/usr/bin/gawk");
register_in(
root.path(),
&[entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100)],
"gawk"
)
.expect("register");
assert_eq!(
read_link(root.path(), "/usr/bin/awk"),
format!("{}/etc/alternatives/awk", root.path().display())
);
assert!(
read_link(root.path(), "/etc/alternatives/awk")
.ends_with("/usr/bin/gawk")
);
}
#[test]
fn highest_priority_wins() {
let root = temp_root();
let r = root.path().to_path_buf();
make_binary(&r, "/usr/bin/mawk");
make_binary(&r, "/usr/bin/gawk");
register_in(
&r,
&[entry("awk", "/usr/bin/mawk", "/usr/bin/awk", 50)],
"mawk"
)
.expect("mawk");
register_in(
&r,
&[entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100)],
"gawk"
)
.expect("gawk");
let group = get_in(&r, "awk").expect("get").expect("group");
assert_eq!(
group.current().map(|a| a.path.as_str()),
Some("/usr/bin/gawk")
);
assert!(
read_link(&r, "/etc/alternatives/awk").ends_with("/usr/bin/gawk")
);
}
#[test]
fn equal_priority_breaks_tie_on_most_recent() {
let root = temp_root();
let r = root.path().to_path_buf();
make_binary(&r, "/usr/bin/gawk");
make_binary(&r, "/usr/bin/nawk");
register_in(
&r,
&[entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100)],
"gawk"
)
.expect("gawk");
register_in(
&r,
&[entry("awk", "/usr/bin/nawk", "/usr/bin/awk", 100)],
"nawk"
)
.expect("nawk");
let group = get_in(&r, "awk").expect("get").expect("group");
assert_eq!(
group.current().map(|a| a.path.as_str()),
Some("/usr/bin/nawk")
);
}
#[test]
fn manual_selection_beats_priority() {
let root = temp_root();
let r = root.path().to_path_buf();
make_binary(&r, "/usr/bin/gawk");
make_binary(&r, "/usr/bin/mawk");
register_in(
&r,
&[entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100)],
"gawk"
)
.expect("gawk");
register_in(
&r,
&[entry("awk", "/usr/bin/mawk", "/usr/bin/awk", 50)],
"mawk"
)
.expect("mawk");
set_in(&r, "awk", "/usr/bin/mawk").expect("set");
assert!(
read_link(&r, "/etc/alternatives/awk").ends_with("/usr/bin/mawk")
);
let group = get_in(&r, "awk").expect("get").expect("group");
assert!(group.status().manual, "status must report a manual choice");
}
#[test]
fn manual_selection_survives_a_higher_priority_install() {
let root = temp_root();
let r = root.path().to_path_buf();
make_binary(&r, "/usr/bin/gawk");
make_binary(&r, "/usr/bin/busybox");
register_in(
&r,
&[entry("vi", "/usr/bin/gawk", "/usr/bin/vi", 100)],
"gawk"
)
.expect("gawk");
set_in(&r, "vi", "/usr/bin/gawk").expect("set");
register_in(
&r,
&[entry("vi", "/usr/bin/busybox", "/usr/bin/vi", 500)],
"busybox"
)
.expect("busybox");
let group = get_in(&r, "vi").expect("get").expect("group");
assert_eq!(
group.current().map(|a| a.path.as_str()),
Some("/usr/bin/gawk"),
"a manual choice must not be undone by an install"
);
}
#[test]
fn auto_drops_the_override_and_returns_to_priority() {
let root = temp_root();
let r = root.path().to_path_buf();
make_binary(&r, "/usr/bin/gawk");
make_binary(&r, "/usr/bin/mawk");
register_in(
&r,
&[entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100)],
"gawk"
)
.expect("gawk");
register_in(
&r,
&[entry("awk", "/usr/bin/mawk", "/usr/bin/awk", 50)],
"mawk"
)
.expect("mawk");
set_in(&r, "awk", "/usr/bin/mawk").expect("set");
let chosen = auto_in(&r, "awk").expect("auto");
assert_eq!(chosen.as_deref(), Some("/usr/bin/gawk"));
assert!(
read_link(&r, "/etc/alternatives/awk").ends_with("/usr/bin/gawk")
);
let group = get_in(&r, "awk").expect("get").expect("group");
assert!(!group.status().manual);
}
#[test]
fn deregistering_the_active_implementation_falls_back() {
let root = temp_root();
let r = root.path().to_path_buf();
make_binary(&r, "/usr/bin/gawk");
make_binary(&r, "/usr/bin/mawk");
register_in(
&r,
&[entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100)],
"gawk"
)
.expect("gawk");
register_in(
&r,
&[entry("awk", "/usr/bin/mawk", "/usr/bin/awk", 50)],
"mawk"
)
.expect("mawk");
deregister_in(&r, "gawk").expect("deregister");
assert!(
read_link(&r, "/etc/alternatives/awk").ends_with("/usr/bin/mawk")
);
assert!(
read_link(&r, "/usr/bin/awk").ends_with("/etc/alternatives/awk")
);
}
#[test]
fn deregistering_the_last_implementation_removes_the_links() {
let root = temp_root();
let r = root.path().to_path_buf();
make_binary(&r, "/usr/bin/gawk");
register_in(
&r,
&[entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100)],
"gawk"
)
.expect("gawk");
deregister_in(&r, "gawk").expect("deregister");
assert!(in_root(&r, "/usr/bin/awk").symlink_metadata().is_err());
assert!(
in_root(&r, "/etc/alternatives/awk")
.symlink_metadata()
.is_err()
);
assert!(get_in(&r, "awk").expect("get").is_none());
}
#[test]
fn deregistering_clears_a_manual_choice_of_the_removed_package() {
let root = temp_root();
let r = root.path().to_path_buf();
make_binary(&r, "/usr/bin/gawk");
make_binary(&r, "/usr/bin/mawk");
register_in(
&r,
&[entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100)],
"gawk"
)
.expect("gawk");
register_in(
&r,
&[entry("awk", "/usr/bin/mawk", "/usr/bin/awk", 50)],
"mawk"
)
.expect("mawk");
set_in(&r, "awk", "/usr/bin/mawk").expect("set");
deregister_in(&r, "mawk").expect("deregister");
let group = get_in(&r, "awk").expect("get").expect("group");
assert_eq!(
group.current().map(|a| a.path.as_str()),
Some("/usr/bin/gawk")
);
assert!(!group.status().manual);
}
#[test]
fn deregistering_an_unrelated_package_changes_nothing() {
let root = temp_root();
let r = root.path().to_path_buf();
make_binary(&r, "/usr/bin/gawk");
register_in(
&r,
&[entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100)],
"gawk"
)
.expect("gawk");
let changed = deregister_in(&r, "unrelated").expect("deregister");
assert_eq!(changed.len(), 0, "changes: {changed:?}");
assert!(
read_link(&r, "/etc/alternatives/awk").ends_with("/usr/bin/gawk")
);
}
#[test]
fn registration_is_idempotent_per_path() {
let root = temp_root();
let r = root.path().to_path_buf();
make_binary(&r, "/usr/bin/gawk");
let e = [entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100)];
register_in(&r, &e, "gawk").expect("first");
register_in(&r, &e, "gawk").expect("second");
let group = get_in(&r, "awk").expect("get").expect("group");
assert_eq!(group.alternatives.len(), 1);
}
#[test]
fn re_registration_refreshes_the_tie_break_position() {
let root = temp_root();
let r = root.path().to_path_buf();
make_binary(&r, "/usr/bin/gawk");
make_binary(&r, "/usr/bin/nawk");
let gawk = [entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100)];
register_in(&r, &gawk, "gawk").expect("gawk");
register_in(
&r,
&[entry("awk", "/usr/bin/nawk", "/usr/bin/awk", 100)],
"nawk"
)
.expect("nawk");
let current = current_path_in(&r, "awk");
assert_eq!(current, "/usr/bin/nawk");
register_in(&r, &gawk, "gawk").expect("reinstall");
let current = current_path_in(&r, "awk");
assert_eq!(current, "/usr/bin/gawk");
}
#[test]
fn multiple_groups_are_tracked_separately() {
let root = temp_root();
let r = root.path().to_path_buf();
make_binary(&r, "/usr/bin/gawk");
make_binary(&r, "/usr/bin/vim");
make_binary(&r, "/usr/bin/nvim");
register_in(
&r,
&[
entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100),
entry("editor", "/usr/bin/vim", "/usr/bin/editor", 100),
entry("vi", "/usr/bin/vim", "/usr/bin/vi", 100)
],
"gvim"
)
.expect("register");
register_in(
&r,
&[entry("editor", "/usr/bin/nvim", "/usr/bin/editor", 50)],
"neovim"
)
.expect("neovim");
let groups = list_in(&r).expect("list");
let names: Vec<&str> = groups.iter().map(|g| g.name.as_str()).collect();
assert_eq!(names, vec!["awk", "editor", "vi"]);
let second = groups.get(1).expect("two groups");
assert_eq!(second.alternatives.len(), 2);
}
#[test]
fn relative_paths_are_rejected() {
let root = temp_root();
let r = root.path().to_path_buf();
let err = register_in(
&r,
&[entry("awk", "usr/bin/gawk", "/usr/bin/awk", 100)],
"gawk"
)
.expect_err("must reject a relative path");
assert!(err.to_string().contains("absolute"));
}
#[test]
fn traversal_in_paths_is_rejected() {
let root = temp_root();
let r = root.path().to_path_buf();
assert!(
register_in(
&r,
&[entry(
"awk",
"/usr/bin/../../etc/shadow",
"/usr/bin/awk",
100
)],
"evil",
)
.is_err()
);
}
#[test]
fn group_names_cannot_escape_the_state_directory() {
let root = temp_root();
let r = root.path().to_path_buf();
assert!(
register_in(
&r,
&[entry("../../escape", "/usr/bin/x", "/usr/bin/x", 1)],
"evil",
)
.is_err()
);
}
#[test]
fn setting_an_unregistered_path_fails() {
let root = temp_root();
let r = root.path().to_path_buf();
make_binary(&r, "/usr/bin/gawk");
register_in(
&r,
&[entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100)],
"gawk"
)
.expect("register");
let err = set_in(&r, "awk", "/usr/bin/nope").expect_err("must reject");
assert!(err.to_string().contains("not a registered alternative"));
}
#[test]
fn removing_an_implementation_falls_back() {
let root = temp_root();
let r = root.path().to_path_buf();
make_binary(&r, "/usr/bin/gawk");
make_binary(&r, "/usr/bin/mawk");
register_in(
&r,
&[
entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100),
entry("awk", "/usr/bin/mawk", "/usr/bin/awk", 50)
],
"combined"
)
.expect("register");
remove_implementation_in(&r, "awk", "/usr/bin/gawk").expect("remove");
assert!(
read_link(&r, "/etc/alternatives/awk").ends_with("/usr/bin/mawk")
);
}
#[test]
fn listing_an_empty_root_is_not_an_error() {
let root = temp_root();
assert_eq!(list_in(root.path()).expect("list").len(), 0);
assert!(get_in(root.path(), "nothing").expect("get").is_none());
}
#[test]
fn symlinks_are_not_rewritten_when_unchanged() {
let root = temp_root();
let r = root.path().to_path_buf();
make_binary(&r, "/usr/bin/gawk");
let e = [entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100)];
register_in(&r, &e, "gawk").expect("first");
let before = fs::symlink_metadata(in_root(&r, "/usr/bin/awk"))
.expect("stat")
.modified()
.expect("mtime");
std::thread::sleep(std::time::Duration::from_millis(1100));
register_in(&r, &e, "gawk").expect("second");
let after = fs::symlink_metadata(in_root(&r, "/usr/bin/awk"))
.expect("stat")
.modified()
.expect("mtime");
assert_eq!(before, after);
}
#[test]
fn registering_a_directory_in_the_way_is_handled() {
let root = temp_root();
let r = root.path().to_path_buf();
make_binary(&r, "/usr/bin/gawk");
fs::create_dir_all(in_root(&r, "/usr/bin/awk")).expect("mkdir");
register_in(
&r,
&[entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100)],
"gawk"
)
.expect("register");
assert!(
in_root(&r, "/usr/bin/awk")
.symlink_metadata()
.expect("stat fixture path")
.is_symlink()
);
}
#[test]
fn status_reports_every_field_the_cli_needs() {
let root = temp_root();
let r = root.path().to_path_buf();
make_binary(&r, "/usr/bin/gawk");
make_binary(&r, "/usr/bin/mawk");
register_in(
&r,
&[
entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100),
entry("awk", "/usr/bin/mawk", "/usr/bin/awk", 50)
],
"combined"
)
.expect("register");
let group = get_in(&r, "awk")
.expect("group exists")
.expect("group is present");
let s = group.status();
assert_eq!(s.name, "awk");
assert_eq!(s.link, "/usr/bin/awk");
assert_eq!(s.current.as_deref(), Some("/usr/bin/gawk"));
assert!(!s.manual);
assert_eq!(s.alternatives.len(), 2);
}
}