Skip to main content

zoi_core/
alternatives.rs

1//! The alternatives mechanism: choosing between interchangeable
2//! implementations.
3//!
4//! ## Why this exists
5//!
6//! Some commands have several interchangeable implementations and the system
7//! still has to put *something* at the canonical path. `awk` may be `gawk`,
8//! `mawk` or `busybox`. `vi` may be `vim`, `nvim` or `vi`. `editor` may be
9//! anything.
10//!
11//! Without a mechanism, the package that happens to install last wins, or the
12//! first one hardcodes a choice into `/usr/bin/awk` and every other
13//! implementation fights it over the same file. Both are bad: changing the
14//! decision means reinstalling packages, and file conflicts between them become
15//! routine.
16//!
17//! ## The layout
18//!
19//! Mirrors Debian's `update-alternatives`, because that layout is understood
20//! and because `/etc/alternatives` is a de-facto interface some software
21//! probes:
22//!
23//! ```text
24//! /usr/bin/awk -> /etc/alternatives/awk -> /usr/bin/gawk
25//! ```
26//!
27//! Real files live in their own locations and are never moved. Only symlinks
28//! are written, which is what makes switching instant and reversible, and what
29//! lets several implementations coexist without touching each other.
30//!
31//! State lives under `/var/lib/zoi/alternatives/<name>/state.json`, one file
32//! per group. A single index file would be simpler but would be rewritten in
33//! full on every install and uninstall, so installing two packages would race.
34//!
35//! ## Choosing a winner
36//!
37//! Highest priority wins. Ties go to the most recently registered
38//! implementation, because that is the one an administrator just installed and
39//! therefore the one they most likely meant to make current.
40//!
41//! `link_set` records a manual override. It is deliberately sticky across
42//! re-registration, so installing a higher-priority package does not silently
43//! undo a decision the administrator made, and the choice is recoverable via
44//! [`auto`](fn@auto).
45//!
46//! Everything is sysroot-aware, so the same code populates a target tree during
47//! `zoi system distro build` and a live root during `zoi install`.
48
49use std::collections::BTreeMap;
50use std::fs;
51use std::path::{Path, PathBuf};
52
53use anyhow::{Context, Result, bail};
54use serde::{Deserialize, Serialize};
55
56use crate::sysroot;
57use crate::types::AlternativeEntry;
58
59/// Directory, relative to the system root, holding alternative group state.
60const STATE_DIR: &str = "var/lib/zoi/alternatives";
61
62/// Directory, relative to the system root, holding the indirect symlinks.
63///
64/// `/etc/alternatives` rather than a Zoi-private path, because some software
65/// already looks there.
66const ETC_ALTERNATIVES_DIR: &str = "etc/alternatives";
67
68/// File, inside a group directory, holding that group's state.
69const STATE_FILE: &str = "state.json";
70
71/// One registered implementation of an alternative.
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
73pub struct Alternative {
74    /// Absolute path of the implementation.
75    pub path: String,
76    /// Priority; higher wins.
77    pub priority: i32,
78    /// Package that registered this implementation.
79    pub package: String,
80    /// Registration order within the group, used to break priority ties.
81    ///
82    /// An explicit counter rather than a timestamp because two registrations
83    /// in the same transaction would otherwise collide.
84    pub sequence: u64
85}
86
87/// State of one alternative group.
88#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
89pub struct AlternativeGroup {
90    /// Group name, e.g. `awk`.
91    pub name: String,
92    /// Absolute path of the canonical link, e.g. `/usr/bin/awk`.
93    pub link: String,
94    /// Every registered implementation, in registration order.
95    pub alternatives: Vec<Alternative>,
96    /// Manually selected path, overriding priority selection.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub link_set: Option<String>
99}
100
101impl AlternativeGroup {
102    /// Returns the implementation currently selected.
103    ///
104    /// A manual override wins over priority, because that is the whole point of
105    /// setting one. When the override names something that is no longer
106    /// registered it is ignored rather than producing a dangling symlink.
107    pub fn current(&self) -> Option<&Alternative> {
108        if let Some(chosen) = &self.link_set
109            && let Some(found) =
110                self.alternatives.iter().find(|a| &a.path == chosen)
111        {
112            return Some(found);
113        }
114
115        self.best_by_priority()
116    }
117
118    /// Returns the highest-priority implementation, breaking ties by the most
119    /// recent registration.
120    fn best_by_priority(&self) -> Option<&Alternative> {
121        self.alternatives
122            .iter()
123            // `max_by` keeps the *last* maximum, which is exactly the
124            // most-recently-registered behaviour wanted on a tie.
125            .max_by(|a, b| {
126                (a.priority, a.sequence).cmp(&(b.priority, b.sequence))
127            })
128    }
129
130    /// Returns the state of an alternative, for reporting.
131    pub fn status(&self) -> AlternativeStatus {
132        let current = self.current();
133        let mut manual = false;
134
135        if let Some(chosen) = &self.link_set
136            && self.alternatives.iter().any(|a| &a.path == chosen)
137        {
138            manual = true;
139        }
140
141        AlternativeStatus {
142            name: self.name.clone(),
143            link: self.link.clone(),
144            current: current.map(|a| a.path.clone()),
145            manual,
146            alternatives: self.alternatives.clone()
147        }
148    }
149}
150
151/// A flattened view of a group, for CLI output and for the library API.
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct AlternativeStatus {
154    /// Group name.
155    pub name: String,
156    /// Canonical link path.
157    pub link: String,
158    /// Path currently selected, if any.
159    pub current: Option<String>,
160    /// Whether the current choice was set manually.
161    pub manual: bool,
162    /// Every registered implementation.
163    pub alternatives: Vec<Alternative>
164}
165
166/// Returns the system root the operations apply to.
167fn root() -> PathBuf {
168    sysroot::get_sysroot().unwrap_or_else(|| PathBuf::from("/"))
169}
170
171/// Resolves an absolute path inside the current sysroot.
172fn in_root(root: &Path, absolute: &str) -> PathBuf {
173    root.join(absolute.trim_start_matches('/'))
174}
175
176/// Rejects a path that is not absolute.
177///
178/// An alternatives entry with a relative path would resolve against whatever
179/// directory the caller happened to be in, and the resulting symlink would
180/// break the moment that changed. Failing loudly is the only safe response.
181fn validate_absolute(field: &str, value: &str) -> Result<()> {
182    if !value.starts_with('/') {
183        bail!("{field} must be an absolute path, got '{value}'");
184    }
185    if value.contains("..") {
186        bail!("{field} must not contain '..', got '{value}'");
187    }
188    Ok(())
189}
190
191/// Rejects a group name that is not safe to use as a filename.
192fn validate_name(name: &str) -> Result<()> {
193    if name.is_empty() {
194        bail!("An alternative name must not be empty");
195    }
196    if name.contains('/') || name.contains('\0') {
197        bail!("Invalid alternative name '{name}': must not contain '/'");
198    }
199    Ok(())
200}
201
202/// Reads every alternative group.
203///
204/// Groups are returned in name order so CLI output is stable, which also makes
205/// this testable without comparing unordered collections.
206///
207/// # Errors
208///
209/// Returns an error only if a group directory exists but its state file cannot
210/// be read. A missing state directory is reported as no groups, not an error.
211pub fn list() -> Result<Vec<AlternativeGroup>> {
212    list_in(&root())
213}
214
215/// The root-explicit form of [`list`].
216///
217/// # Errors
218///
219/// Propagates a failure to read a group state file. Individual unreadable
220/// groups are skipped rather than failing the whole listing, so one bad
221/// directory cannot hide every other group.
222pub fn list_in(root: &Path) -> Result<Vec<AlternativeGroup>> {
223    let dir = root.join(STATE_DIR);
224    let Ok(entries) = fs::read_dir(&dir) else {
225        // No state directory simply means nothing has been registered.
226        return Ok(Vec::new());
227    };
228
229    let mut groups = BTreeMap::new();
230
231    for entry in entries.flatten() {
232        let path = entry.path();
233        if !path.is_dir() {
234            continue;
235        }
236        let Ok(group) = read_group(&path) else {
237            // A corrupt or half-written group is skipped rather than failing
238            // the whole listing; one bad directory must not hide
239            // every other group.
240            continue;
241        };
242        groups.insert(group.name.clone(), group);
243    }
244
245    Ok(groups.into_values().collect())
246}
247
248/// Reads one group by name.
249///
250/// # Errors
251///
252/// Returns an error if `name` is not usable as a filename, or if the group
253/// exists but its state file cannot be read or parsed.
254pub fn get(name: &str) -> Result<Option<AlternativeGroup>> {
255    get_in(&root(), name)
256}
257
258/// The root-explicit form of [`get`].
259///
260/// # Errors
261///
262/// Returns an error if `name` is not usable as a filename, or if the group
263/// exists but its state file cannot be read or parsed.
264pub fn get_in(root: &Path, name: &str) -> Result<Option<AlternativeGroup>> {
265    validate_name(name)?;
266    let path = root.join(STATE_DIR).join(name);
267    if !path.is_dir() {
268        return Ok(None);
269    }
270    Ok(Some(read_group(&path)?))
271}
272
273/// Reads a group from its directory.
274fn read_group(dir: &Path) -> Result<AlternativeGroup> {
275    let path = dir.join(STATE_FILE);
276    let content = fs::read_to_string(&path)
277        .with_context(|| format!("Failed to read {}", path.display()))?;
278    serde_json::from_str(&content)
279        .with_context(|| format!("Failed to parse {}", path.display()))
280}
281
282/// Writes a group's state.
283///
284/// Written to a temporary file and renamed, so a crash mid-write cannot leave a
285/// truncated `state.json` that would lose every registration in the group.
286fn write_group(root: &Path, group: &AlternativeGroup) -> Result<()> {
287    let dir = root.join(STATE_DIR).join(&group.name);
288    fs::create_dir_all(&dir)
289        .with_context(|| format!("Failed to create {}", dir.display()))?;
290
291    let final_path = dir.join(STATE_FILE);
292    let temp_path = dir.join(format!("{STATE_FILE}.tmp"));
293
294    fs::write(&temp_path, serde_json::to_string_pretty(group)?)
295        .with_context(|| format!("Failed to write {}", temp_path.display()))?;
296    fs::rename(&temp_path, &final_path).with_context(|| {
297        format!("Failed to replace {}", final_path.display())
298    })?;
299
300    Ok(())
301}
302
303/// Returns the next registration sequence number for a group.
304///
305/// Derived from the highest sequence already present, so it stays correct after
306/// a deregistration removes the most recent entry.
307fn next_sequence(group: &AlternativeGroup) -> u64 {
308    group
309        .alternatives
310        .iter()
311        .map(|a| a.sequence)
312        .max()
313        .unwrap_or(0)
314        + 1
315}
316
317/// Registers the alternatives declared by a package.
318///
319/// Idempotent per `(name, path)`: re-registering an existing entry refreshes
320/// its priority and moves it to the end of the tie-break order rather than
321/// creating a duplicate. That matters because a kernel-style upgrade re-runs
322/// the registration path.
323///
324/// # Errors
325///
326/// Returns an error if an entry has a non-absolute or traversing path, an
327/// unusable group name, or if the state directory or a symlink cannot be
328/// written.
329pub fn register(
330    entries: &[AlternativeEntry],
331    package: &str
332) -> Result<Vec<String>> {
333    register_in(&root(), entries, package)
334}
335
336/// The root-explicit form of [`register`].
337///
338/// # Errors
339///
340/// As [`register`], with paths resolved under `root`.
341pub fn register_in(
342    root: &Path,
343    entries: &[AlternativeEntry],
344    package: &str
345) -> Result<Vec<String>> {
346    if entries.is_empty() {
347        return Ok(Vec::new());
348    }
349
350    let mut changed = Vec::new();
351
352    // Grouped by name so each group's state is read and written once, rather
353    // than once per entry.
354    let mut grouped: BTreeMap<&str, Vec<&AlternativeEntry>> = BTreeMap::new();
355    for entry in entries {
356        validate_name(&entry.name)?;
357        validate_absolute("alternatives.path", &entry.path)?;
358        validate_absolute("alternatives.link", &entry.link)?;
359        grouped.entry(&entry.name).or_default().push(entry);
360    }
361
362    for (name, group_entries) in grouped {
363        // `grouped` is keyed by name, so this iteration always yields at least
364        // one entry. `first()` rather than an index so that is a type-level
365        // guarantee instead of a panic waiting for a future refactor.
366        let Some(first_entry) = group_entries.first() else {
367            continue;
368        };
369
370        let mut group = get_in(root, name)?.unwrap_or(AlternativeGroup {
371            name: name.to_string(),
372            link: first_entry.link.clone(),
373            alternatives: Vec::new(),
374            link_set: None
375        });
376
377        // A group's link is a property of the group, not of one registration.
378        // If two packages disagree, the first registration wins and the
379        // mismatch is reported rather than silently reassigning the
380        // canonical path, which could point `/usr/bin/awk` somewhere a
381        // package does not own.
382        if group.link != first_entry.link {
383            eprintln!(
384                "Warning: alternative '{name}' is registered with link '{}' \
385                 but '{}' expects '{}'. Keeping '{}'.",
386                group.link, package, first_entry.link, group.link
387            );
388        }
389
390        let before = group.current().map(|a| a.path.clone());
391
392        for entry in group_entries {
393            let sequence = next_sequence(&group);
394
395            match group.alternatives.iter_mut().find(|a| a.path == entry.path) {
396                Some(existing) => {
397                    existing.priority = entry.priority;
398                    existing.package = package.to_string();
399                    // Re-registration counts as a fresh registration, so a
400                    // reinstalled package takes the tie-break.
401                    existing.sequence = sequence;
402                }
403                None => group.alternatives.push(Alternative {
404                    path: entry.path.clone(),
405                    priority: entry.priority,
406                    package: package.to_string(),
407                    sequence
408                })
409            }
410        }
411
412        let after = group.current().map(|a| a.path.clone());
413        write_group(root, &group)?;
414        apply_links(root, &group)?;
415
416        if before != after {
417            changed.push(name.to_string());
418        }
419    }
420
421    Ok(changed)
422}
423
424/// Removes every alternative a package registered.
425///
426/// This is what lets two implementations of the same command coexist: removing
427/// one leaves the other selected without any file conflict having occurred.
428///
429/// # Errors
430///
431/// Returns an error if a group state file cannot be read or written. A group
432/// left with no implementations is removed entirely rather than left dangling.
433pub fn deregister(package: &str) -> Result<Vec<String>> {
434    deregister_in(&root(), package)
435}
436
437/// The root-explicit form of [`deregister`].
438///
439/// # Errors
440///
441/// As [`deregister`], with paths resolved under `root`.
442pub fn deregister_in(root: &Path, package: &str) -> Result<Vec<String>> {
443    let groups = list_in(root)?;
444    let mut changed = Vec::new();
445
446    for mut group in groups {
447        if !group.alternatives.iter().any(|a| a.package == package) {
448            continue;
449        }
450
451        let before = group.current().map(|a| a.path.clone());
452        group.alternatives.retain(|a| a.package != package);
453
454        // A manual override pointing at the removed package is dropped, or the
455        // group would keep selecting something that no longer exists.
456        if group.link_set.as_ref().is_some_and(|chosen| {
457            group.alternatives.iter().all(|a| &a.path != chosen)
458        }) {
459            group.link_set = None;
460        }
461
462        let after = group.current().map(|a| a.path.clone());
463
464        if group.alternatives.is_empty() {
465            // The last implementation is gone, so the whole group goes with it.
466            // Leaving behind an empty group would keep a dangling
467            // `/etc/alternatives/<name>` that nothing resolves.
468            remove_group(root, &group)?;
469        } else {
470            write_group(root, &group)?;
471            apply_links(root, &group)?;
472        }
473
474        if before != after {
475            changed.push(group.name);
476        }
477    }
478
479    Ok(changed)
480}
481
482/// Deletes a group and the symlinks it owns.
483fn remove_group(root: &Path, group: &AlternativeGroup) -> Result<()> {
484    let _ = fs::remove_file(in_root(root, &group.link));
485    let _ = fs::remove_file(in_root(root, &etc_path(&group.name)?));
486    let _ = fs::remove_dir_all(root.join(STATE_DIR).join(&group.name));
487    Ok(())
488}
489
490/// Manually selects an implementation.
491///
492/// # Errors
493///
494/// Returns an error if `name` is not usable as a filename, if no such group
495/// exists, or if `path` is not one of the group's registered implementations.
496/// Accepting an unknown path would produce a symlink to a file that does not
497/// exist.
498pub fn set(name: &str, path: &str) -> Result<()> {
499    set_in(&root(), name, path)
500}
501
502/// The root-explicit form of [`set`].
503///
504/// # Errors
505///
506/// As [`set`], with paths resolved under `root`.
507pub fn set_in(root: &Path, name: &str, path: &str) -> Result<()> {
508    let Some(mut group) = get_in(root, name)? else {
509        bail!("No such alternative: '{name}'. Run 'zoi alt list' to see them.");
510    };
511
512    if !group.alternatives.iter().any(|a| a.path == path) {
513        bail!(
514            "'{path}' is not a registered alternative for '{name}'. \
515             Available: {}",
516            group
517                .alternatives
518                .iter()
519                .map(|a| a.path.clone())
520                .collect::<Vec<_>>()
521                .join(", ")
522        );
523    }
524
525    group.link_set = Some(path.to_string());
526    write_group(root, &group)?;
527    apply_links(root, &group)?;
528
529    Ok(())
530}
531
532/// Drops a manual override, returning to priority-based selection.
533///
534/// # Errors
535///
536/// Returns an error if `name` is not usable as a filename, if no such group
537/// exists, or if the state cannot be written.
538pub fn auto(name: &str) -> Result<Option<String>> {
539    auto_in(&root(), name)
540}
541
542/// The root-explicit form of [`auto`](fn@auto).
543///
544/// # Errors
545///
546/// As [`auto`](fn@auto), with paths resolved under `root`.
547pub fn auto_in(root: &Path, name: &str) -> Result<Option<String>> {
548    let Some(mut group) = get_in(root, name)? else {
549        bail!("No such alternative: '{name}'.");
550    };
551
552    group.link_set = None;
553    let selected = group.current().map(|a| a.path.clone());
554
555    write_group(root, &group)?;
556    apply_links(root, &group)?;
557
558    Ok(selected)
559}
560
561/// Removes one implementation from a group.
562///
563/// Used when a package's file is removed but the package itself stays, and by
564/// `zoi alt remove` for correcting a registration by hand.
565///
566/// # Errors
567///
568/// Returns an error if `name` is not usable as a filename, if no such group
569/// exists, or if `path` is not registered for it.
570pub fn remove_implementation(name: &str, path: &str) -> Result<()> {
571    remove_implementation_in(&root(), name, path)
572}
573
574/// The root-explicit form of [`remove_implementation`].
575///
576/// # Errors
577///
578/// As [`remove_implementation`], with paths resolved under `root`.
579pub fn remove_implementation_in(
580    root: &Path,
581    name: &str,
582    path: &str
583) -> Result<()> {
584    let Some(mut group) = get_in(root, name)? else {
585        bail!("No such alternative: '{name}'.");
586    };
587
588    let before = group.alternatives.len();
589    group.alternatives.retain(|a| a.path != path);
590
591    if group.alternatives.len() == before {
592        bail!("'{path}' is not registered as an alternative for '{name}'.");
593    }
594
595    if group.link_set.as_deref() == Some(path) {
596        group.link_set = None;
597    }
598
599    if group.alternatives.is_empty() {
600        remove_group(root, &group)?;
601    } else {
602        write_group(root, &group)?;
603        apply_links(root, &group)?;
604    }
605
606    Ok(())
607}
608
609/// Returns the `/etc/alternatives` path for a group.
610fn etc_path(name: &str) -> Result<String> {
611    validate_name(name)?;
612    Ok(format!("/{ETC_ALTERNATIVES_DIR}/{name}"))
613}
614
615/// Points a group's symlinks at its selected implementation.
616///
617/// Two links are maintained: `/etc/alternatives/<name>` at the implementation,
618/// and the canonical `<link>` at `/etc/alternatives/<name>`. The indirection is
619/// what makes switching a single `rename` rather than a rewrite of the
620/// canonical path, and it keeps `/usr/bin/awk` pointing at something stable
621/// even while a different implementation is current.
622fn apply_links(root: &Path, group: &AlternativeGroup) -> Result<()> {
623    let etc_dir = root.join(ETC_ALTERNATIVES_DIR);
624    fs::create_dir_all(&etc_dir)
625        .with_context(|| format!("Failed to create {}", etc_dir.display()))?;
626
627    let etc_link = etc_dir.join(&group.name);
628
629    if let Some(selected) = group.current() {
630        symlink_to(root, &selected.path, &etc_link)?;
631
632        // The canonical link points at the intermediate, never directly at
633        // the implementation.
634        let canonical = in_root(root, &group.link);
635        symlink_to(root, &etc_path(&group.name)?, &canonical)?;
636    } else {
637        // Nothing registered. Leaving the links behind would produce a
638        // dangling `/usr/bin/awk`, which breaks every caller in a way that is
639        // much harder to diagnose than a missing command.
640        let _ = fs::remove_file(&etc_link);
641        let _ = fs::remove_file(in_root(root, &group.link));
642    }
643
644    Ok(())
645}
646
647/// Creates or replaces a symlink, doing nothing when it already resolves to the
648/// target.
649///
650/// Replacing an identical symlink would change its mtime for no reason, and
651/// some integrity tooling treats that as tampering.
652fn symlink_to(root: &Path, target: &str, link: &Path) -> Result<()> {
653    // `target` is absolute from the system's point of view. Inside a sysroot
654    // the link has to be relative to the target root, otherwise the
655    // produced symlink would point outside the image being built.
656    let effective = if root == Path::new("/") {
657        target.to_string()
658    } else {
659        in_root(root, target).to_string_lossy().to_string()
660    };
661
662    if let Ok(existing) = fs::read_link(link)
663        && existing.to_string_lossy() == effective
664    {
665        return Ok(());
666    }
667
668    if let Some(parent) = link.parent() {
669        fs::create_dir_all(parent).with_context(|| {
670            format!("Failed to create {}", parent.display())
671        })?;
672    }
673
674    // `symlink` fails when the path already exists, so the old link is cleared
675    // first. Removing by name rather than following it means a symlink to a
676    // directory is replaced rather than having its target emptied.
677    match fs::symlink_metadata(link) {
678        Ok(meta) if meta.is_dir() => {
679            fs::remove_dir_all(link).with_context(|| {
680                format!("Failed to replace directory {}", link.display())
681            })?;
682        }
683        Ok(_) => {
684            fs::remove_file(link).with_context(|| {
685                format!("Failed to remove {}", link.display())
686            })?;
687        }
688        Err(_) => {}
689    }
690
691    // Windows has no `symlink`: `symlink_dir` and `symlink_file` are separate
692    // entry points and Windows rejects the call when the link type disagrees
693    // with the target. Both kinds of link are produced here,
694    // `/etc/alternatives` and the canonical path point at the intermediate
695    // while the group link points at the implementation, so the target
696    // decides which one to use.
697    #[cfg(unix)]
698    let created = std::os::unix::fs::symlink(&effective, link);
699    #[cfg(windows)]
700    let created = if Path::new(&effective).is_dir() {
701        std::os::windows::fs::symlink_dir(&effective, link)
702    } else {
703        std::os::windows::fs::symlink_file(&effective, link)
704    };
705
706    created.with_context(|| {
707        format!("Failed to create symlink {} -> {effective}", link.display())
708    })?;
709
710    Ok(())
711}
712
713/// Returns the status of every group, for CLI output.
714///
715/// # Errors
716///
717/// Propagates a failure to read the state directory listing.
718pub fn status() -> Result<Vec<AlternativeStatus>> {
719    Ok(list()?.iter().map(AlternativeGroup::status).collect())
720}
721
722/// Reconciles the alternatives state for the packages a transaction touched.
723///
724/// # Errors
725///
726/// Returns an error if a touched package's group state cannot be read or
727/// written, or if a symlink cannot be created. A failure here leaves the
728/// previous state intact rather than a partial one, because group state is
729/// written to a temporary file and renamed.
730pub fn reconcile(touched: &[String]) -> Result<Vec<String>> {
731    reconcile_in(&root(), touched)
732}
733
734/// The root-explicit form of [`reconcile`].
735///
736/// # Errors
737///
738/// As [`reconcile`], with paths resolved under `root`.
739pub fn reconcile_in(root: &Path, touched: &[String]) -> Result<Vec<String>> {
740    if touched.is_empty() {
741        return Ok(Vec::new());
742    }
743
744    // Deduplicated and sorted so a package listed twice, or listed in a
745    // different order on a different run, produces identical state.
746    let mut names: Vec<String> = touched.to_vec();
747    names.sort();
748    names.dedup();
749
750    let mut changed = Vec::new();
751
752    for name in &names {
753        let manifest = find_installed(root, name);
754
755        match manifest {
756            Some(manifest) => {
757                let entries = manifest.alternatives.unwrap_or_default();
758
759                // Also drop this package's registrations for groups it no
760                // longer declares. Without this, removing an
761                // alternative from a `.pkg.lua` and
762                // reinstalling would leave the old one active.
763                let stale = stale_groups(root, name, &entries)?;
764                for group in stale {
765                    if deregister_group(root, &group, name)? {
766                        changed.push(group);
767                    }
768                }
769
770                if entries.is_empty() {
771                    continue;
772                }
773
774                changed.extend(register_in(root, &entries, name)?);
775            }
776            None => {
777                changed.extend(deregister_in(root, name)?);
778            }
779        }
780    }
781
782    changed.sort();
783    changed.dedup();
784    Ok(changed)
785}
786
787/// Returns the package store directories to search under `root`.
788///
789/// Both scopes are included because a user-scope package can legitimately
790/// register an alternative: a canonical path inside the user's own `PATH` is
791/// just as ambiguous as one under `/usr/bin`.
792///
793/// `utils::get_store_base_dir` already applies the global sysroot, so it cannot
794/// be combined with an explicit `root` without applying the prefix twice. The
795/// paths are therefore built directly from `root`, and the platform's system
796/// data directory tail is reused so the layout stays in step with the rest of
797/// Zoi.
798fn store_dirs(root: &Path) -> Vec<PathBuf> {
799    let mut dirs = Vec::new();
800
801    // System scope. The tail is taken from the platform-specific system data
802    // directory so this does not drift from `utils::get_system_data_dir`.
803    dirs.push(join_under(root, Path::new("/var/lib/zoi/pkgs/store")));
804
805    if root == Path::new("/") {
806        // On the live system the user store is wherever XDG points, which only
807        // `utils` can resolve.
808        if let Ok(user_store) =
809            crate::utils::get_store_base_dir(crate::types::Scope::User)
810        {
811            dirs.push(user_store);
812        }
813    } else {
814        // Inside a sysroot the user store is the XDG default under that root.
815        // Honoured so a staged image is reconciled the same way the installed
816        // system will be.
817        dirs.push(root.join(".local/share/zoi/pkgs/store"));
818    }
819
820    dirs
821}
822
823/// Joins an absolute path under `root`, avoiding a duplicated prefix.
824fn join_under(root: &Path, absolute: &Path) -> PathBuf {
825    if root == Path::new("/") {
826        return absolute.to_path_buf();
827    }
828    root.join(absolute.strip_prefix("/").unwrap_or(absolute))
829}
830
831/// Finds a package's install manifest.
832///
833/// Reads manifests straight out of the store rather than through the package
834/// database, because this has to work inside a sysroot during a distro build
835/// where no database is populated.
836///
837/// The store nests as `<store>/<hash>-<name>/<version>/manifest.json`, where
838/// the directory name comes from `utils::get_package_dir_name`. Matching the
839/// `-<name>` suffix is therefore what identifies a package.
840fn find_installed(
841    root: &Path,
842    name: &str
843) -> Option<crate::types::InstallManifest> {
844    let suffix = format!("-{name}");
845
846    for store in store_dirs(root) {
847        let Ok(entries) = fs::read_dir(&store) else {
848            continue;
849        };
850
851        for entry in entries.flatten() {
852            let pkg_dir = entry.path();
853            if !pkg_dir.is_dir() {
854                continue;
855            }
856
857            let dir_name = pkg_dir.file_name().and_then(|n| n.to_str());
858            let Some(dir_name) = dir_name else {
859                continue;
860            };
861            if !dir_name.ends_with(&suffix) {
862                continue;
863            }
864
865            let Ok(versions) = fs::read_dir(&pkg_dir) else {
866                continue;
867            };
868
869            for version in versions.flatten() {
870                let manifest_path = version.path().join("manifest.json");
871                let Ok(content) = fs::read_to_string(&manifest_path) else {
872                    continue;
873                };
874                if let Ok(manifest) = serde_json::from_str::<
875                    crate::types::InstallManifest
876                >(&content)
877                {
878                    return Some(manifest);
879                }
880            }
881        }
882    }
883
884    None
885}
886
887/// Returns groups where `package` holds a registration that is no longer
888/// declared.
889///
890/// Returns the group names, so the caller can deregister just those.
891fn stale_groups(
892    root: &Path,
893    package: &str,
894    declared: &[AlternativeEntry]
895) -> Result<Vec<String>> {
896    let groups = list_in(root)?;
897
898    let mut stale = Vec::new();
899
900    for group in groups {
901        // Only groups this package still participates in.
902        let owned_paths: Vec<&str> = group
903            .alternatives
904            .iter()
905            .filter(|a| a.package == package)
906            .map(|a| a.path.as_str())
907            .collect();
908
909        if owned_paths.is_empty() {
910            continue;
911        }
912
913        // Of those, any the package no longer declares in its definition.
914        let no_longer_declared = owned_paths.iter().any(|path| {
915            !declared
916                .iter()
917                .any(|entry| entry.name == group.name && entry.path == **path)
918        });
919
920        if no_longer_declared {
921            stale.push(group.name);
922        }
923    }
924
925    Ok(stale)
926}
927
928/// Deregisters one package from one group.
929///
930/// Returns whether the selected implementation changed.
931fn deregister_group(root: &Path, name: &str, package: &str) -> Result<bool> {
932    let Some(mut group) = get_in(root, name)? else {
933        return Ok(false);
934    };
935
936    let before = group.current().map(|a| a.path.clone());
937    group.alternatives.retain(|a| a.package != package);
938
939    if group.link_set.as_ref().is_some_and(|chosen| {
940        group.alternatives.iter().all(|a| &a.path != chosen)
941    }) {
942        group.link_set = None;
943    }
944
945    let after = group.current().map(|a| a.path.clone());
946
947    if group.alternatives.is_empty() {
948        remove_group(root, &group)?;
949    } else {
950        write_group(root, &group)?;
951        apply_links(root, &group)?;
952    }
953
954    Ok(before != after)
955}
956
957#[cfg(test)]
958mod tests {
959    use super::*;
960
961    fn temp_root() -> tempfile::TempDir {
962        tempfile::tempdir().expect("temp dir")
963    }
964
965    fn entry(
966        name: &str,
967        path: &str,
968        link: &str,
969        priority: i32
970    ) -> AlternativeEntry {
971        AlternativeEntry {
972            name: name.to_string(),
973            path: path.to_string(),
974            link: link.to_string(),
975            priority
976        }
977    }
978
979    /// Creates a fake implementation file so symlinks resolve to something
980    /// real.
981    fn make_binary(root: &Path, path: &str) {
982        let full = in_root(root, path);
983        fs::create_dir_all(full.parent().expect("parent")).expect("mkdir");
984        fs::write(&full, "#!/bin/true\n").expect("write");
985    }
986
987    fn read_link(root: &Path, path: &str) -> String {
988        fs::read_link(in_root(root, path))
989            .expect("link")
990            .to_string_lossy()
991            .to_string()
992    }
993
994    /// Returns the currently selected path of a group, failing the test if the
995    /// group or its selection is missing.
996    ///
997    /// Collapses what would otherwise be a chain of three `expect` calls at
998    /// every assertion site, and names what was actually missing when it
999    /// fails.
1000    fn current_path_in(root: &Path, name: &str) -> String {
1001        let group = get_in(root, name)
1002            .expect("group lookup succeeds")
1003            .unwrap_or_else(|| panic!("alternative '{name}' should exist"));
1004
1005        group
1006            .current()
1007            .unwrap_or_else(|| {
1008                panic!("alternative '{name}' should have a selection")
1009            })
1010            .path
1011            .clone()
1012    }
1013
1014    #[test]
1015    fn registration_links_the_canonical_path() {
1016        let root = temp_root();
1017        make_binary(root.path(), "/usr/bin/gawk");
1018
1019        register_in(
1020            root.path(),
1021            &[entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100)],
1022            "gawk"
1023        )
1024        .expect("register");
1025
1026        // The two-hop layout is what makes switching safe.
1027        assert_eq!(
1028            read_link(root.path(), "/usr/bin/awk"),
1029            format!("{}/etc/alternatives/awk", root.path().display())
1030        );
1031        assert!(
1032            read_link(root.path(), "/etc/alternatives/awk")
1033                .ends_with("/usr/bin/gawk")
1034        );
1035    }
1036
1037    #[test]
1038    fn highest_priority_wins() {
1039        let root = temp_root();
1040        let r = root.path().to_path_buf();
1041        make_binary(&r, "/usr/bin/mawk");
1042        make_binary(&r, "/usr/bin/gawk");
1043
1044        register_in(
1045            &r,
1046            &[entry("awk", "/usr/bin/mawk", "/usr/bin/awk", 50)],
1047            "mawk"
1048        )
1049        .expect("mawk");
1050        register_in(
1051            &r,
1052            &[entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100)],
1053            "gawk"
1054        )
1055        .expect("gawk");
1056
1057        let group = get_in(&r, "awk").expect("get").expect("group");
1058        assert_eq!(
1059            group.current().map(|a| a.path.as_str()),
1060            Some("/usr/bin/gawk")
1061        );
1062        assert!(
1063            read_link(&r, "/etc/alternatives/awk").ends_with("/usr/bin/gawk")
1064        );
1065    }
1066
1067    #[test]
1068    fn equal_priority_breaks_tie_on_most_recent() {
1069        let root = temp_root();
1070        let r = root.path().to_path_buf();
1071        make_binary(&r, "/usr/bin/gawk");
1072        make_binary(&r, "/usr/bin/nawk");
1073
1074        register_in(
1075            &r,
1076            &[entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100)],
1077            "gawk"
1078        )
1079        .expect("gawk");
1080        register_in(
1081            &r,
1082            &[entry("awk", "/usr/bin/nawk", "/usr/bin/awk", 100)],
1083            "nawk"
1084        )
1085        .expect("nawk");
1086
1087        // Same priority, so the later registration wins: it is the one the
1088        // administrator most likely just installed on purpose.
1089        let group = get_in(&r, "awk").expect("get").expect("group");
1090        assert_eq!(
1091            group.current().map(|a| a.path.as_str()),
1092            Some("/usr/bin/nawk")
1093        );
1094    }
1095
1096    #[test]
1097    fn manual_selection_beats_priority() {
1098        let root = temp_root();
1099        let r = root.path().to_path_buf();
1100        make_binary(&r, "/usr/bin/gawk");
1101        make_binary(&r, "/usr/bin/mawk");
1102
1103        register_in(
1104            &r,
1105            &[entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100)],
1106            "gawk"
1107        )
1108        .expect("gawk");
1109        register_in(
1110            &r,
1111            &[entry("awk", "/usr/bin/mawk", "/usr/bin/awk", 50)],
1112            "mawk"
1113        )
1114        .expect("mawk");
1115
1116        set_in(&r, "awk", "/usr/bin/mawk").expect("set");
1117        assert!(
1118            read_link(&r, "/etc/alternatives/awk").ends_with("/usr/bin/mawk")
1119        );
1120
1121        let group = get_in(&r, "awk").expect("get").expect("group");
1122        assert!(group.status().manual, "status must report a manual choice");
1123    }
1124
1125    #[test]
1126    fn manual_selection_survives_a_higher_priority_install() {
1127        let root = temp_root();
1128        let r = root.path().to_path_buf();
1129        make_binary(&r, "/usr/bin/gawk");
1130        make_binary(&r, "/usr/bin/busybox");
1131
1132        register_in(
1133            &r,
1134            &[entry("vi", "/usr/bin/gawk", "/usr/bin/vi", 100)],
1135            "gawk"
1136        )
1137        .expect("gawk");
1138        set_in(&r, "vi", "/usr/bin/gawk").expect("set");
1139
1140        // A much higher priority arrives. Silently overriding the administrator
1141        // would be the wrong default, so the manual choice stands.
1142        register_in(
1143            &r,
1144            &[entry("vi", "/usr/bin/busybox", "/usr/bin/vi", 500)],
1145            "busybox"
1146        )
1147        .expect("busybox");
1148
1149        let group = get_in(&r, "vi").expect("get").expect("group");
1150        assert_eq!(
1151            group.current().map(|a| a.path.as_str()),
1152            Some("/usr/bin/gawk"),
1153            "a manual choice must not be undone by an install"
1154        );
1155    }
1156
1157    #[test]
1158    fn auto_drops_the_override_and_returns_to_priority() {
1159        let root = temp_root();
1160        let r = root.path().to_path_buf();
1161        make_binary(&r, "/usr/bin/gawk");
1162        make_binary(&r, "/usr/bin/mawk");
1163
1164        register_in(
1165            &r,
1166            &[entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100)],
1167            "gawk"
1168        )
1169        .expect("gawk");
1170        register_in(
1171            &r,
1172            &[entry("awk", "/usr/bin/mawk", "/usr/bin/awk", 50)],
1173            "mawk"
1174        )
1175        .expect("mawk");
1176        set_in(&r, "awk", "/usr/bin/mawk").expect("set");
1177
1178        let chosen = auto_in(&r, "awk").expect("auto");
1179        assert_eq!(chosen.as_deref(), Some("/usr/bin/gawk"));
1180        assert!(
1181            read_link(&r, "/etc/alternatives/awk").ends_with("/usr/bin/gawk")
1182        );
1183
1184        let group = get_in(&r, "awk").expect("get").expect("group");
1185        assert!(!group.status().manual);
1186    }
1187
1188    #[test]
1189    fn deregistering_the_active_implementation_falls_back() {
1190        let root = temp_root();
1191        let r = root.path().to_path_buf();
1192        make_binary(&r, "/usr/bin/gawk");
1193        make_binary(&r, "/usr/bin/mawk");
1194
1195        register_in(
1196            &r,
1197            &[entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100)],
1198            "gawk"
1199        )
1200        .expect("gawk");
1201        register_in(
1202            &r,
1203            &[entry("awk", "/usr/bin/mawk", "/usr/bin/awk", 50)],
1204            "mawk"
1205        )
1206        .expect("mawk");
1207
1208        deregister_in(&r, "gawk").expect("deregister");
1209
1210        // This is the whole point: removing one implementation must not break
1211        // the command, it must fall back to the other.
1212        assert!(
1213            read_link(&r, "/etc/alternatives/awk").ends_with("/usr/bin/mawk")
1214        );
1215        assert!(
1216            read_link(&r, "/usr/bin/awk").ends_with("/etc/alternatives/awk")
1217        );
1218    }
1219
1220    #[test]
1221    fn deregistering_the_last_implementation_removes_the_links() {
1222        let root = temp_root();
1223        let r = root.path().to_path_buf();
1224        make_binary(&r, "/usr/bin/gawk");
1225
1226        register_in(
1227            &r,
1228            &[entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100)],
1229            "gawk"
1230        )
1231        .expect("gawk");
1232        deregister_in(&r, "gawk").expect("deregister");
1233
1234        // A dangling /usr/bin/awk is worse than a missing one.
1235        assert!(in_root(&r, "/usr/bin/awk").symlink_metadata().is_err());
1236        assert!(
1237            in_root(&r, "/etc/alternatives/awk")
1238                .symlink_metadata()
1239                .is_err()
1240        );
1241        assert!(get_in(&r, "awk").expect("get").is_none());
1242    }
1243
1244    #[test]
1245    fn deregistering_clears_a_manual_choice_of_the_removed_package() {
1246        let root = temp_root();
1247        let r = root.path().to_path_buf();
1248        make_binary(&r, "/usr/bin/gawk");
1249        make_binary(&r, "/usr/bin/mawk");
1250
1251        register_in(
1252            &r,
1253            &[entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100)],
1254            "gawk"
1255        )
1256        .expect("gawk");
1257        register_in(
1258            &r,
1259            &[entry("awk", "/usr/bin/mawk", "/usr/bin/awk", 50)],
1260            "mawk"
1261        )
1262        .expect("mawk");
1263        set_in(&r, "awk", "/usr/bin/mawk").expect("set");
1264
1265        deregister_in(&r, "mawk").expect("deregister");
1266
1267        let group = get_in(&r, "awk").expect("get").expect("group");
1268        // The override named something that no longer exists, so it is dropped
1269        // rather than left to select a missing file.
1270        assert_eq!(
1271            group.current().map(|a| a.path.as_str()),
1272            Some("/usr/bin/gawk")
1273        );
1274        assert!(!group.status().manual);
1275    }
1276
1277    #[test]
1278    fn deregistering_an_unrelated_package_changes_nothing() {
1279        let root = temp_root();
1280        let r = root.path().to_path_buf();
1281        make_binary(&r, "/usr/bin/gawk");
1282
1283        register_in(
1284            &r,
1285            &[entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100)],
1286            "gawk"
1287        )
1288        .expect("gawk");
1289
1290        let changed = deregister_in(&r, "unrelated").expect("deregister");
1291        assert_eq!(changed.len(), 0, "changes: {changed:?}");
1292        assert!(
1293            read_link(&r, "/etc/alternatives/awk").ends_with("/usr/bin/gawk")
1294        );
1295    }
1296
1297    #[test]
1298    fn registration_is_idempotent_per_path() {
1299        let root = temp_root();
1300        let r = root.path().to_path_buf();
1301        make_binary(&r, "/usr/bin/gawk");
1302
1303        let e = [entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100)];
1304        register_in(&r, &e, "gawk").expect("first");
1305        register_in(&r, &e, "gawk").expect("second");
1306
1307        let group = get_in(&r, "awk").expect("get").expect("group");
1308        // A duplicate would make `zoi alt list` confusing and could
1309        // double-count during a transition.
1310        assert_eq!(group.alternatives.len(), 1);
1311    }
1312
1313    #[test]
1314    fn re_registration_refreshes_the_tie_break_position() {
1315        let root = temp_root();
1316        let r = root.path().to_path_buf();
1317        make_binary(&r, "/usr/bin/gawk");
1318        make_binary(&r, "/usr/bin/nawk");
1319
1320        let gawk = [entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100)];
1321        register_in(&r, &gawk, "gawk").expect("gawk");
1322        register_in(
1323            &r,
1324            &[entry("awk", "/usr/bin/nawk", "/usr/bin/awk", 100)],
1325            "nawk"
1326        )
1327        .expect("nawk");
1328        let current = current_path_in(&r, "awk");
1329        assert_eq!(current, "/usr/bin/nawk");
1330
1331        // Reinstalling gawk is a deliberate act, so it should take priority
1332        // back.
1333        register_in(&r, &gawk, "gawk").expect("reinstall");
1334        let current = current_path_in(&r, "awk");
1335        assert_eq!(current, "/usr/bin/gawk");
1336    }
1337
1338    #[test]
1339    fn multiple_groups_are_tracked_separately() {
1340        let root = temp_root();
1341        let r = root.path().to_path_buf();
1342        make_binary(&r, "/usr/bin/gawk");
1343        make_binary(&r, "/usr/bin/vim");
1344        make_binary(&r, "/usr/bin/nvim");
1345
1346        register_in(
1347            &r,
1348            &[
1349                entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100),
1350                entry("editor", "/usr/bin/vim", "/usr/bin/editor", 100),
1351                entry("vi", "/usr/bin/vim", "/usr/bin/vi", 100)
1352            ],
1353            "gvim"
1354        )
1355        .expect("register");
1356
1357        // `vi` and `editor` legitimately point at the same implementation; they
1358        // are separate groups because they are separate canonical links.
1359        register_in(
1360            &r,
1361            &[entry("editor", "/usr/bin/nvim", "/usr/bin/editor", 50)],
1362            "neovim"
1363        )
1364        .expect("neovim");
1365
1366        let groups = list_in(&r).expect("list");
1367        let names: Vec<&str> = groups.iter().map(|g| g.name.as_str()).collect();
1368        assert_eq!(names, vec!["awk", "editor", "vi"]);
1369        let second = groups.get(1).expect("two groups");
1370        assert_eq!(second.alternatives.len(), 2);
1371    }
1372
1373    #[test]
1374    fn relative_paths_are_rejected() {
1375        let root = temp_root();
1376        let r = root.path().to_path_buf();
1377
1378        // A relative path would resolve against the caller's working directory,
1379        // and the symlink would break the moment that changed.
1380        let err = register_in(
1381            &r,
1382            &[entry("awk", "usr/bin/gawk", "/usr/bin/awk", 100)],
1383            "gawk"
1384        )
1385        .expect_err("must reject a relative path");
1386        assert!(err.to_string().contains("absolute"));
1387    }
1388
1389    #[test]
1390    fn traversal_in_paths_is_rejected() {
1391        let root = temp_root();
1392        let r = root.path().to_path_buf();
1393
1394        assert!(
1395            register_in(
1396                &r,
1397                &[entry(
1398                    "awk",
1399                    "/usr/bin/../../etc/shadow",
1400                    "/usr/bin/awk",
1401                    100
1402                )],
1403                "evil",
1404            )
1405            .is_err()
1406        );
1407    }
1408
1409    #[test]
1410    fn group_names_cannot_escape_the_state_directory() {
1411        let root = temp_root();
1412        let r = root.path().to_path_buf();
1413
1414        // A name with a slash would place state outside `/var/lib/zoi`.
1415        assert!(
1416            register_in(
1417                &r,
1418                &[entry("../../escape", "/usr/bin/x", "/usr/bin/x", 1)],
1419                "evil",
1420            )
1421            .is_err()
1422        );
1423    }
1424
1425    #[test]
1426    fn setting_an_unregistered_path_fails() {
1427        let root = temp_root();
1428        let r = root.path().to_path_buf();
1429        make_binary(&r, "/usr/bin/gawk");
1430        register_in(
1431            &r,
1432            &[entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100)],
1433            "gawk"
1434        )
1435        .expect("register");
1436
1437        // Accepting it would create a symlink to a file that does not exist.
1438        let err = set_in(&r, "awk", "/usr/bin/nope").expect_err("must reject");
1439        assert!(err.to_string().contains("not a registered alternative"));
1440    }
1441
1442    #[test]
1443    fn removing_an_implementation_falls_back() {
1444        let root = temp_root();
1445        let r = root.path().to_path_buf();
1446        make_binary(&r, "/usr/bin/gawk");
1447        make_binary(&r, "/usr/bin/mawk");
1448
1449        register_in(
1450            &r,
1451            &[
1452                entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100),
1453                entry("awk", "/usr/bin/mawk", "/usr/bin/awk", 50)
1454            ],
1455            "combined"
1456        )
1457        .expect("register");
1458
1459        remove_implementation_in(&r, "awk", "/usr/bin/gawk").expect("remove");
1460        assert!(
1461            read_link(&r, "/etc/alternatives/awk").ends_with("/usr/bin/mawk")
1462        );
1463    }
1464
1465    #[test]
1466    fn listing_an_empty_root_is_not_an_error() {
1467        let root = temp_root();
1468        assert_eq!(list_in(root.path()).expect("list").len(), 0);
1469        assert!(get_in(root.path(), "nothing").expect("get").is_none());
1470    }
1471
1472    #[test]
1473    fn symlinks_are_not_rewritten_when_unchanged() {
1474        let root = temp_root();
1475        let r = root.path().to_path_buf();
1476        make_binary(&r, "/usr/bin/gawk");
1477        let e = [entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100)];
1478
1479        register_in(&r, &e, "gawk").expect("first");
1480        let before = fs::symlink_metadata(in_root(&r, "/usr/bin/awk"))
1481            .expect("stat")
1482            .modified()
1483            .expect("mtime");
1484
1485        std::thread::sleep(std::time::Duration::from_millis(1100));
1486        register_in(&r, &e, "gawk").expect("second");
1487        let after = fs::symlink_metadata(in_root(&r, "/usr/bin/awk"))
1488            .expect("stat")
1489            .modified()
1490            .expect("mtime");
1491
1492        // Rewriting an identical symlink would look like tampering to integrity
1493        // tooling for no benefit.
1494        assert_eq!(before, after);
1495    }
1496
1497    #[test]
1498    fn registering_a_directory_in_the_way_is_handled() {
1499        let root = temp_root();
1500        let r = root.path().to_path_buf();
1501        make_binary(&r, "/usr/bin/gawk");
1502
1503        // Something else left a real directory where the canonical link goes.
1504        fs::create_dir_all(in_root(&r, "/usr/bin/awk")).expect("mkdir");
1505
1506        register_in(
1507            &r,
1508            &[entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100)],
1509            "gawk"
1510        )
1511        .expect("register");
1512
1513        assert!(
1514            in_root(&r, "/usr/bin/awk")
1515                .symlink_metadata()
1516                .expect("stat fixture path")
1517                .is_symlink()
1518        );
1519    }
1520
1521    #[test]
1522    fn status_reports_every_field_the_cli_needs() {
1523        let root = temp_root();
1524        let r = root.path().to_path_buf();
1525        make_binary(&r, "/usr/bin/gawk");
1526        make_binary(&r, "/usr/bin/mawk");
1527
1528        register_in(
1529            &r,
1530            &[
1531                entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100),
1532                entry("awk", "/usr/bin/mawk", "/usr/bin/awk", 50)
1533            ],
1534            "combined"
1535        )
1536        .expect("register");
1537
1538        let group = get_in(&r, "awk")
1539            .expect("group exists")
1540            .expect("group is present");
1541        let s = group.status();
1542        assert_eq!(s.name, "awk");
1543        assert_eq!(s.link, "/usr/bin/awk");
1544        assert_eq!(s.current.as_deref(), Some("/usr/bin/gawk"));
1545        assert!(!s.manual);
1546        assert_eq!(s.alternatives.len(), 2);
1547    }
1548}