Skip to main content

max / alloy

Report what the installed stack is behind, on a keypress The system tab gains u: it diffs the installed packages against the repos the image was built from and reports the result in two halves, a curated watchlist of high-exposure packages named with versions and a bare count for everything else. Merging the two is the failure mode, because "247 behind" every week is a screen people learn to dismiss. Baking the curated stack into the image is only affordable if the machine says when it has drifted, which is what this is for. Three properties are constraints rather than choices. It is a keypress and never a timer or a poll, because an installed machine reaching out on its own would undo what alloy@85ee0e6 established. It names each disabled build-time repo explicitly, because the Containerfile turns terra, tailscale and the COPRs off post-install and dnf would otherwise diff against a fraction of where the stack came from. And it does not claim to be a security check: a version diff says a newer build exists, not that the installed one is vulnerable. The remedy is printed, not run. GO alloy 7eff0de2.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-01 22:13 UTC
Signed with PGP, not checked
Commit: 148c90cbd5c8a44c9187054f5447cdf976fca8ea
Parent: 37ca1e3
4 files changed, +650 insertions, -15 deletions
@@ -73,6 +73,26 @@
73 73 top-level name because this document already specced it. Design in the wiki note
74 74 `alloy-package-ux`.
75 75
76 + The system tab carries one thing the other two do not: `u` checks what is behind.
77 + Baking the curated stack into the image is affordable only if the machine says
78 + when it has drifted (wiki note `alloy-packaging-policy`), so the tab diffs the
79 + installed packages against the repos the image was built from and reports the
80 + result in two halves — a curated watchlist of high-exposure packages, named with
81 + versions, and a bare count for everything else. Merging them is the failure mode:
82 + "247 behind" every week is a screen people learn to dismiss.
83 +
84 + Three properties of that check are constraints rather than choices. It is a
85 + keypress, never a timer or a poll, because an installed machine reaching out on
86 + its own would undo what `alloy@85ee0e6` established. It names each disabled
87 + build-time repo explicitly on the command line, because the Containerfile turns
88 + terra, tailscale and the COPRs off post-install and dnf would otherwise diff
89 + against a fraction of where the stack came from. And it never claims to be a
90 + security check: a version diff says a newer build exists, not that the installed
91 + one is vulnerable, and real advisory tracking is a much larger commitment than
92 + this. The remedy is printed, not run. Whether the console should drive the
93 + rebuild and the `bootc switch` itself belongs to the builder TUI, which is not
94 + designed yet.
95 +
76 96 The boxes tab does not front a single CLI. A box's isolation level picks its
77 97 backend (`host` to distrobox, `workspace` to podman directly, `sandboxed` to
78 98 flatpak), so the user chooses isolation and Alloy chooses the implementation. That
@@ -23,6 +23,7 @@
23 23 mod settings;
24 24 mod setup;
25 25 mod shell;
26 + mod stale;
26 27 mod status;
27 28 mod store;
28 29 mod sync;
@@ -71,6 +71,7 @@
71 71
72 72 use crate::cli::{CommandLog, Effect, Invocation};
73 73 use crate::shell::{Confirm, Flow, View, block_title, truncate};
74 + use crate::stale::{self, Staleness};
74 75
75 76 /// Ticks between background refreshes.
76 77 ///
@@ -1248,6 +1249,15 @@
1248 1249 ///
1249 1250 /// [`has_rpm`]: PkgView::has_rpm
1250 1251 rpm: Option<Result<Status>>,
1252 + /// Whether dnf is on this machine, probed once. Guards the staleness check
1253 + /// rather than the tab: a machine without dnf can still show deployments.
1254 + has_dnf: bool,
1255 + /// The last staleness check, or the error from trying. `None` until the user
1256 + /// presses `u`, and it stays `None` otherwise — that is the pull-not-push
1257 + /// constraint, and [`refresh`] deliberately does not touch it.
1258 + ///
1259 + /// [`refresh`]: PkgView::refresh
1260 + stale: Option<Result<Staleness>>,
1251 1261 }
1252 1262
1253 1263 impl PkgView {
@@ -1266,6 +1276,8 @@
1266 1276 ticks: 0,
1267 1277 has_rpm: Rpm::present(),
1268 1278 rpm: None,
1279 + has_dnf: stale::available(),
1280 + stale: None,
1269 1281 };
1270 1282 view.refresh(log);
1271 1283 view
@@ -1663,27 +1675,108 @@
1663 1675 }
1664 1676 };
1665 1677
1666 - let [list_area, summary_area] =
1667 - Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(area);
1678 + // The deployments take exactly the rows they need and the staleness
1679 + // block takes the rest. The other way round — deployments on `Min` —
1680 + // would grow a two-row list into half the screen and push the thing the
1681 + // user pressed a key for off the bottom.
1682 + let deployment_rows = u16::try_from(status.deployments.len().max(1)).unwrap_or(u16::MAX);
1683 + let [list_area, summary_area, _gap, stale_area] = Layout::vertical([
1684 + Constraint::Length(deployment_rows),
1685 + Constraint::Length(1),
1686 + Constraint::Length(1),
1687 + Constraint::Min(0),
1688 + ])
1689 + .areas(area);
1668 1690
1669 1691 if status.deployments.is_empty() {
1670 1692 frame.render_widget(Line::from(text::muted(theme, "no deployments")), list_area);
1693 + } else {
1694 + let rows: Vec<Line> = status
1695 + .deployments
1696 + .iter()
1697 + .map(|dep| Self::deployment_row(theme, dep))
1698 + .collect();
1699 + frame.render_widget(AlloyList::new(theme, rows), list_area);
1700 +
1701 + let pinned = status.deployments.iter().filter(|d| d.pinned).count();
1702 + let mut summary = format!("{} deployments", status.deployments.len());
1703 + if pinned > 0 {
1704 + let _ = write!(summary, ", {pinned} pinned");
1705 + }
1706 + frame.render_widget(Line::from(text::muted(theme, summary)), summary_area);
1707 + }
1708 +
1709 + self.render_staleness(frame, stale_area, theme);
1710 + }
1711 +
1712 + /// What a rebuild would change, under the deployments it would add to.
1713 + ///
1714 + /// Four states, and the unchecked one is the default rather than a failure:
1715 + /// nothing here reaches the network until the user asks it to (wiki
1716 + /// `alloy-packaging-policy`, and see [`stale`](crate::stale) on why the pull
1717 + /// is a keypress rather than something construction does).
1718 + fn render_staleness(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
1719 + if area.height == 0 {
1671 1720 return;
1672 1721 }
1673 1722
1674 - let rows: Vec<Line> = status
1675 - .deployments
1676 - .iter()
1677 - .map(|dep| Self::deployment_row(theme, dep))
1678 - .collect();
1679 - frame.render_widget(AlloyList::new(theme, rows), list_area);
1723 + let mut lines: Vec<Line> = Vec::new();
1680 1724
1681 - let pinned = status.deployments.iter().filter(|d| d.pinned).count();
1682 - let mut summary = format!("{} deployments", status.deployments.len());
1683 - if pinned > 0 {
1684 - let _ = write!(summary, ", {pinned} pinned");
1725 + match &self.stale {
1726 + None if !self.has_dnf => {
1727 + lines.push(Line::from(text::muted(
1728 + theme,
1729 + "no dnf: cannot check what is behind",
1730 + )));
1731 + }
1732 + None => {
1733 + lines.push(Line::from(text::muted(
1734 + theme,
1735 + "press u to check what is behind (refreshes repo metadata; takes a moment)",
1736 + )));
1737 + }
1738 + Some(Err(err)) => {
1739 + lines.push(Line::from(text::muted(
1740 + theme,
1741 + format!("check failed: {err}"),
1742 + )));
1743 + }
1744 + Some(Ok(staleness)) => lines.extend(
1745 + staleness
1746 + .report()
1747 + .iter()
1748 + .map(|row| Self::staleness_row(row, theme)),
1749 + ),
1750 + }
1751 +
1752 + frame.render_widget(AlloyList::new(theme, lines), area);
1753 + }
1754 +
1755 + /// Paint one report row. [`Staleness::report`] owns the words; this owns
1756 + /// only what they look like.
1757 + fn staleness_row<'a>(row: &stale::Row, theme: &Theme) -> Line<'a> {
1758 + match row {
1759 + stale::Row::Heading { title, note } => Line::from(vec![
1760 + text::bold(theme, title.clone()),
1761 + text::muted(theme, format!(" {note}")),
1762 + ]),
1763 + stale::Row::Package {
1764 + name,
1765 + installed,
1766 + available,
1767 + repo,
1768 + } => Line::from(vec![
1769 + text::bold(theme, format!("{:<24}", truncate(name, 23))),
1770 + text::muted(theme, format!("{:<20}", truncate(installed, 19))),
1771 + text::muted(theme, "-> "),
1772 + text::secondary(theme, format!("{:<20}", truncate(available, 19))),
1773 + text::muted(theme, truncate(repo, 24)),
1774 + ]),
1775 + stale::Row::Note(prose) => Line::from(text::muted(theme, prose.clone())),
1776 + stale::Row::Command(command) => {
1777 + Line::from(text::secondary(theme, format!(" {command}")))
1778 + }
1685 1779 }
1686 - frame.render_widget(Line::from(text::muted(theme, summary)), summary_area);
1687 1780 }
1688 1781
1689 1782 fn deployment_row<'a>(theme: &Theme, dep: &'a Deployment) -> Line<'a> {
@@ -1740,9 +1833,15 @@
1740 1833 hint("x", "remove"),
1741 1834 ]);
1742 1835 }
1743 - // Every tab refreshes; the rpm-ostree tabs have no per-row actions yet,
1744 - // so refresh is the whole of their interaction.
1836 + // Every tab refreshes; the installed tab has no per-row actions yet, so
1837 + // refresh is the whole of its interaction.
1745 1838 hints.push(hint("r", "refresh"));
1839 + // The system tab has one more: the check for what is behind. Offered
1840 + // only where it can run, since a hint for a key that does nothing is
1841 + // worse than no hint.
1842 + if self.tab() == Tab::System && self.has_dnf {
1843 + hints.push(hint("u", "check behind"));
1844 + }
1746 1845 hints
1747 1846 }
1748 1847
@@ -1817,6 +1916,16 @@
1817 1916 return Flow::Continue;
1818 1917 }
1819 1918
1919 + // The staleness check, and the only key in this view that touches the
1920 + // network. It is a keypress rather than part of `refresh` on purpose:
1921 + // refresh runs on the background tick, and a polling staleness check
1922 + // would undo `alloy@85ee0e6`'s promise that an installed machine never
1923 + // reaches out on its own. See [`stale`](crate::stale).
1924 + if self.tab() == Tab::System && key.code == KeyCode::Char('u') && self.has_dnf {
1925 + self.stale = Some(stale::check(log));
1926 + return Flow::Continue;
1927 + }
1928 +
1820 1929 if self.tab() != Tab::Boxes {
1821 1930 return Flow::Continue;
1822 1931 }
@@ -2698,6 +2807,11 @@
2698 2807 // deterministic. The rpm-ostree parser is covered on its own above.
2699 2808 has_rpm: false,
2700 2809 rpm: None,
2810 + // Same reasoning as `has_rpm`: the fixture must not reach the host.
2811 + // `false` also pins the unchecked-state copy, since a fixture that
2812 + // claimed dnf would offer a key these tests cannot press.
2813 + has_dnf: false,
2814 + stale: None,
2701 2815 };
2702 2816 view.cursor.resize(view.boxes.len());
2703 2817 (view, CommandLog::new())
@@ -1,0 +1,837 @@
1 + //! What is behind: the staleness half of `alloy update`.
2 + //!
3 + //! Baking the curated stack into the image (wiki `alloy-packaging-policy`) is
4 + //! affordable because the machine tells you when it has drifted. Max, 2026-07-30:
5 + //! trust Alloy users to update or to accept the consequences, but tell them.
6 + //! This module is the telling. The remedy is a rebuild, and the verb that opens
7 + //! this screen already exists — `alloy update` is the system tab of
8 + //! [`pkg`](crate::pkg), which shows what is booted, staged and rollback-able.
9 + //! Staleness is the row above that: what a rebuild would actually change.
10 + //!
11 + //! # Pull, not push, and stricter than it had to be
12 + //!
13 + //! `alloy@85ee0e6` deliberately made non-development machines never reach out:
14 + //! the update timer stays disabled unless the install medium named an update
15 + //! target. A background staleness poll would undo that silently, so there is no
16 + //! timer and no polling here.
17 + //!
18 + //! It goes one step further than the constraint required. The check is a
19 + //! keypress on the system tab, not something [`PkgView::new`] or the background
20 + //! tick does. Two reasons, and the second is the load-bearing one:
21 + //!
22 + //! - `alloy pkg` and `alloy pkg box` construct the same view. Hanging a network
23 + //! fetch off construction would make two verbs that are about local inventory
24 + //! reach out, which is exactly the property `85ee0e6` was protecting.
25 + //! - Refreshing repo metadata takes seconds. The console runs every action
26 + //! synchronously (see [`Invocation::run`]), so a check on construction is a
27 + //! screen that hangs before it first draws, with nothing on it to explain why.
28 + //!
29 + //! So the pull is a key the user presses, on a screen that says what pressing it
30 + //! costs. Whether `alloy update` should go further and drive the rebuild itself
31 + //! is open, and deliberately not answered here — see "The remedy is printed"
32 + //! below.
33 + //!
34 + //! # Noise is the failure mode
35 + //!
36 + //! The image carries hundreds of packages and dozens move in any given week. A
37 + //! screen that says "247 behind" every time trains people to dismiss it, which
38 + //! is worse than not having the screen. So the report has two halves that are
39 + //! never merged: a curated [`WATCHLIST`] of high-exposure packages, listed by
40 + //! name with versions, and a bulk count for everything else. The list is short
41 + //! enough to read; the count is one number and carries no rows.
42 + //!
43 + //! # Behind-ness is not CVE-awareness
44 + //!
45 + //! A version diff says a newer build exists. It does not say the installed one
46 + //! is vulnerable, and it does not say the newer one is a security fix. Real
47 + //! vulnerability tracking means consuming Fedora security advisories or OSV,
48 + //! which is a much larger commitment than this. Every string this module renders
49 + //! is written so that nothing here can be read as a security scan; if you edit
50 + //! the copy, keep that property.
51 + //!
52 + //! # The repos are disabled post-install
53 + //!
54 + //! `Containerfile:1298` sets `enabled=0` on terra, tailscale and the generated
55 + //! `_copr:*` repos, because bootc-image-builder's installer depsolve reads every
56 + //! enabled repo and chokes on their file:// GPG keys. That is correct for the
57 + //! build and it means dnf on an installed machine cannot see the repos half the
58 + //! curated stack came from — including Terra, which is where the browser now
59 + //! comes from. So the checker does not ask dnf what it can see. It reads
60 + //! `/etc/yum.repos.d` itself ([`Repo::parse`]), picks out the ones the
61 + //! Containerfile turned off ([`build_repos`]), and names each one explicitly on
62 + //! the command line. The log pane therefore shows exactly which repos were
63 + //! consulted, which is the property the whole console is built on.
64 + //!
65 + //! Fedora's own disabled repos are deliberately **not** re-enabled. `updates-testing`
66 + //! and friends ship disabled because you are not supposed to be running them;
67 + //! diffing against them would report a machine as behind the moment a build
68 + //! lands in testing, which is the noise failure mode with extra steps.
69 + //!
70 + //! # The remedy is printed, not run
71 + //!
72 + //! The task that filed this left one thing open: whether `alloy update` reports
73 + //! only and prints the commands, or drives the rebuild and the `bootc switch`
74 + //! itself. That question belongs to the builder TUI (GO alloy `1372b159`), which
75 + //! owns build-and-write for installer media and has not been designed yet.
76 + //! Printing is the answer that does not pre-empt it, and it is what the rest of
77 + //! the console does anyway: docs/CONSOLE.md commits every action to being shown
78 + //! as the argv it runs, and teaching a command the user could have typed is the
79 + //! house style rather than a fallback.
80 + //!
81 + //! # What this does not need
82 + //!
83 + //! Not the rebuild lockfile, despite both being filed together. The lockfile's
84 + //! blocking question is that Fedora keeps only the newest build of each package,
85 + //! so a pinned NEVRA set expires and an old image cannot be rebuilt. That gates
86 + //! recreating a *past* image. It does not touch this direction: the staleness
87 + //! check compares what is installed now against what is available now, and
88 + //! "only the newest build is kept" is precisely what makes the comparison
89 + //! meaningful. The installed set is not read from a lockfile either — it is read
90 + //! from the machine's own rpmdb, which is the record.
91 + //!
92 + //! <!-- wiki: alloy-packaging-policy -->
93 +
94 + use std::collections::BTreeMap;
95 + use std::fmt::Write as _;
96 + use std::path::Path;
97 +
98 + use anyhow::{Context, Result};
99 +
100 + use crate::cli::{CommandLog, Invocation};
101 +
102 + /// Where dnf keeps repo definitions. A constant so the tests can point
103 + /// [`Repo::parse`] at a fixture directory instead.
104 + const REPOS_DIR: &str = "/etc/yum.repos.d";
105 +
106 + /// Packages worth naming individually when they fall behind.
107 + ///
108 + /// Curated deliberately, because the curation is what keeps the screen worth
109 + /// reading. Two things earn a place and nothing else does: **parses input from
110 + /// somewhere you do not control**, or **is a privilege or isolation boundary**.
111 + /// Size, popularity and how often a package moves are all irrelevant.
112 + ///
113 + /// | group | why |
114 + /// |---|---|
115 + /// | browser | the single most exposed program on the machine, and the reason `alloy-packaging-policy` needed this check before it could bake it in |
116 + /// | kernel | network stack, filesystem parsers, every driver |
117 + /// | core runtime | glibc's resolver and systemd's PID-1 surface sit under everything |
118 + /// | TLS | the transport every other item on this list trusts |
119 + /// | network daemons | reachable from the LAN without anyone logging in |
120 + /// | privilege | what stands between a session and root |
121 + /// | sandbox | `alloy pkg box`'s isolation rungs are worth exactly what these are |
122 + /// | parsers | decompressors and fetchers, which are where the memory-safety bugs live |
123 + ///
124 + /// Being on this list is not a claim that the package is vulnerable. See the
125 + /// module docs.
126 + const WATCHLIST: &[&str] = &[
127 + // Browser. Helium is the default (wiki `alloy-packaging-policy`); Firefox is
128 + // the Gecko alternative and is one Containerfile line away, so both are
129 + // watched and whichever is absent simply never appears.
130 + "helium-browser-bin",
131 + "firefox",
132 + // Kernel.
133 + "kernel",
134 + "kernel-core",
135 + "kernel-modules",
136 + "kernel-modules-core",
137 + // Core runtime.
138 + "glibc",
139 + "systemd",
140 + "systemd-libs",
141 + "systemd-resolved",
142 + // TLS and crypto.
143 + "openssl",
144 + "openssl-libs",
145 + "gnutls",
146 + "nss",
147 + "nspr",
148 + "p11-kit",
149 + // Reachable over the LAN. avahi is load-bearing rather than incidental: the
150 + // headless install flow resolves `<name>.local` to find the machine at all.
151 + "openssh",
152 + "openssh-server",
153 + "openssh-clients",
154 + "avahi",
155 + "avahi-libs",
156 + "tailscale",
157 + // Privilege boundaries.
158 + "polkit",
159 + "polkit-libs",
160 + "shadow-utils",
161 + // Isolation. A `sandboxed` box is worth what bubblewrap and flatpak are, and
162 + // a `workspace` box is worth what crun is.
163 + "podman",
164 + "crun",
165 + "bubblewrap",
166 + "flatpak",
167 + // Parsers of things that arrived from elsewhere.
168 + "curl",
169 + "libcurl",
170 + "expat",
171 + "libxml2",
172 + "xz-libs",
173 + "zlib-ng-compat",
174 + "libzstd",
175 + ];
176 +
177 + /// One package that has a newer build available.
178 + #[derive(Debug, Clone, PartialEq, Eq)]
179 + pub(crate) struct Behind {
180 + pub(crate) name: String,
181 + /// EVR on this machine. `None` when the rpmdb read did not name it, which
182 + /// happens for a package available in a repo but not installed — dnf counts
183 + /// those as upgrades of a different arch or a rename. Rendered as `?` rather
184 + /// than dropped, because silently discarding a row makes the count and the
185 + /// list disagree.
186 + pub(crate) installed: Option<String>,
187 + /// EVR the repos offer.
188 + pub(crate) available: String,
189 + /// Which repo offers it. Worth showing: "behind on terra" and "behind on
190 + /// fedora" are different facts about how the machine drifted.
191 + pub(crate) repo: String,
192 + }
193 +
194 + /// The result of one check.
195 + #[derive(Debug, Clone, Default, PartialEq, Eq)]
196 + pub(crate) struct Staleness {
197 + /// Watchlist packages that are behind, in [`WATCHLIST`] order rather than
198 + /// alphabetical: the order is a rough exposure ranking and the browser
199 + /// belonging at the top is not an accident.
200 + pub(crate) watched: Vec<Behind>,
201 + /// How many packages are behind that are *not* on the watchlist. A count and
202 + /// never a list — see the module docs on noise.
203 + pub(crate) bulk: usize,
204 + /// The repos this check actually consulted, for the summary line. A user who
205 + /// sees "0 behind" deserves to know whether terra was among them.
206 + pub(crate) repos: Vec<String>,
207 + }
208 +
209 + impl Staleness {
210 + /// Whether the machine is level with every repo that was consulted.
211 + pub(crate) fn current(&self) -> bool {
212 + self.watched.is_empty() && self.bulk == 0
213 + }
214 +
215 + /// The commands that bring the machine forward, in order.
216 + ///
217 + /// Printed rather than run; see the module docs. `build-image.sh` rather
218 + /// than an inlined `podman build` on purpose — the script owns whether the
219 + /// build escalates with sudo or run0 (GO alloy `3587c247` moves it), and
220 + /// duplicating the command here would make this screen wrong the day that
221 + /// lands.
222 + pub(crate) const REMEDY: [&'static str; 3] = [
223 + "build/build-image.sh",
224 + "bootc switch --transport containers-storage localhost/alloy:local",
225 + "systemctl reboot",
226 + ];
227 +
228 + /// The whole report, as words rather than as paint.
229 + ///
230 + /// The words live here and the styling lives in [`pkg`](crate::pkg). That
231 + /// split is not tidiness: the constraint that none of this may read as a
232 + /// vulnerability scan is a property of the strings, and a `Line` full of
233 + /// styled spans cannot be asserted on without a [`Theme`] this crate has no
234 + /// fixture for. Copy here is copy a test can read.
235 + ///
236 + /// [`Theme`]: alloy_tui::Theme
237 + pub(crate) fn report(&self) -> Vec<Row> {
238 + if self.current() {
239 + return vec![Row::Note(format!(
240 + "level with all {} repos this image was built from",
241 + self.repos.len()
242 + ))];
243 + }
244 +
245 + let mut rows = vec![Row::Heading {
246 + title: "behind".to_string(),
247 + note: "newer builds exist; this is not a vulnerability check".to_string(),
248 + }];
249 +
250 + rows.extend(self.watched.iter().map(|behind| Row::Package {
251 + name: behind.name.clone(),
252 + // A watched package the rpmdb did not name renders as `?` rather
253 + // than being dropped: silently discarding a row would make the list
254 + // and the count disagree.
255 + installed: behind.installed.clone().unwrap_or_else(|| "?".to_string()),
256 + available: behind.available.clone(),
257 + repo: behind.repo.clone(),
258 + }));
259 +
260 + let mut summary = if self.watched.is_empty() {
261 + "nothing watched is behind".to_string()
262 + } else {
263 + format!("{} watched", self.watched.len())
264 + };
265 + if self.bulk > 0 {
266 + let _ = write!(summary, ", {} other packages behind", self.bulk);
267 + }
268 + rows.push(Row::Note(summary));
269 +
270 + rows.push(Row::Note("rebuild to update:".to_string()));
271 + rows.extend(Self::REMEDY.map(|command| Row::Command(command.to_string())));
272 +
273 + rows
274 + }
275 + }
276 +
277 + /// One line of the report. [`pkg`](crate::pkg) decides what each looks like.
278 + #[derive(Debug, Clone, PartialEq, Eq)]
279 + pub(crate) enum Row {
280 + /// The heading and the disclaimer that has to travel with it.
281 + Heading { title: String, note: String },
282 + /// One package that is behind.
283 + Package {
284 + name: String,
285 + installed: String,
286 + available: String,
287 + repo: String,
288 + },
289 + /// Prose.
290 + Note(String),
291 + /// A command the user could type. Never run by the console; see the module
292 + /// docs on why the remedy is printed.
293 + Command(String),
294 + }
295 +
296 + impl Row {
297 + /// Every string this row puts on screen, for the copy tests.
298 + #[cfg(test)]
299 + fn words(&self) -> Vec<&str> {
300 + match self {
301 + Row::Heading { title, note } => vec![title, note],
302 + Row::Package {
303 + name,
304 + installed,
305 + available,
306 + repo,
307 + } => vec![name, installed, available, repo],
308 + Row::Note(text) | Row::Command(text) => vec![text],
309 + }
310 + }
311 + }
312 +
313 + /// A repo definition as it appears on disk.
314 + #[derive(Debug, Clone, PartialEq, Eq)]
315 + pub(crate) struct Repo {
316 + pub(crate) id: String,
317 + pub(crate) enabled: bool,
318 + }
319 +
320 + impl Repo {
321 + /// Parse one `.repo` file's contents.
322 + ///
323 + /// Hand-rolled rather than pulled from an ini crate: the format in play is
324 + /// section headers and `key=value`, dnf's own parser accepts far more than
325 + /// is ever written here, and the only two things this needs are the section
326 + /// names and one boolean. A dependency to read six lines would cost more
327 + /// than it saves.
328 + ///
329 + /// A section with no `enabled` key at all is enabled — that is dnf's default
330 + /// and the base Fedora repos rely on it.
331 + pub(crate) fn parse(contents: &str) -> Vec<Repo> {
332 + let mut repos: Vec<Repo> = Vec::new();
333 +
334 + for line in contents.lines() {
335 + let line = line.trim();
336 + if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
337 + continue;
338 + }
339 +
340 + if let Some(id) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
341 + repos.push(Repo {
342 + id: id.trim().to_string(),
343 + enabled: true,
344 + });
345 + continue;
346 + }
347 +
348 + let Some((key, value)) = line.split_once('=') else {
349 + continue;
350 + };
351 + // A key before any section header is malformed; dnf ignores it and
352 + // so does this.
353 + if key.trim() == "enabled"
354 + && let Some(current) = repos.last_mut()
355 + {
356 + current.enabled = matches!(value.trim(), "1" | "True" | "true");
357 + }
358 + }
359 +
360 + repos
361 + }
362 +
363 + /// Every repo defined under `dir`.
364 + ///
365 + /// A file that cannot be read is skipped rather than fatal. The failure this
366 + /// guards is one unreadable drop-in taking down a check that the other
367 + /// twenty repos could have answered.
368 + fn load(dir: &Path) -> Result<Vec<Repo>> {
369 + let entries =
370 + std::fs::read_dir(dir).with_context(|| format!("cannot read {}", dir.display()))?;
371 +
372 + let mut repos = Vec::new();
373 + for entry in entries.flatten() {
374 + let path = entry.path();
375 + if path.extension().is_none_or(|ext| ext != "repo") {
376 + continue;
377 + }
378 + if let Ok(contents) = std::fs::read_to_string(&path) {
379 + repos.extend(Repo::parse(&contents));
380 + }
381 + }
382 + repos.sort_by(|a, b| a.id.cmp(&b.id));
383 + repos.dedup();
384 + Ok(repos)
385 + }
386 + }
387 +
388 + /// The disabled repos the image was built from, which the query must re-enable.
389 + ///
390 + /// Matched against what `Containerfile:1298` actually turns off — terra,
391 + /// tailscale, and the generated COPRs — rather than against "everything that is
392 + /// disabled". The difference matters: Fedora ships `updates-testing` disabled
393 + /// because you should not be running it, and enabling it here would report a
394 + /// machine as behind the moment any build lands in testing.
395 + ///
396 + /// Prefix rather than equality for terra and copr. Terra's release package has
397 + /// carried more than one repo id over its life, and the COPR ids are generated
398 + /// as `copr:copr.fedorainfracloud.org:<owner>:<project>`, so neither is a fixed
399 + /// string worth pinning.
400 + pub(crate) fn build_repos(repos: &[Repo]) -> Vec<String> {
401 + repos
402 + .iter()
403 + .filter(|repo| !repo.enabled)
404 + .filter(|repo| {
405 + repo.id.starts_with("terra")
406 + || repo.id.starts_with("tailscale")
407 + || repo.id.starts_with("copr:")
408 + })
409 + .map(|repo| repo.id.clone())
410 + .collect()
411 + }
412 +
413 + /// `dnf repoquery --upgrades`, with the build repos named explicitly.
414 + ///
415 + /// `--refresh` is the network call, and it is the reason this whole module is
416 + /// keypress-driven: without it dnf answers from cached metadata that may predate
417 + /// the install.
418 + fn upgrades_query(enable: &[String]) -> Invocation {
419 + let mut query = Invocation::new("dnf")
420 + .args(["--quiet", "repoquery", "--upgrades", "--refresh"])
421 + .args(enable.iter().map(|id| format!("--enablerepo={id}")));
422 + // Tab-separated because a package name cannot contain a tab and an EVR
423 + // cannot either, where a space would make `reponame` ambiguous for the
424 + // COPR ids, which contain colons and dots but are still one field.
425 + query = query
426 + .arg("--queryformat")
427 + .arg("%{name}\t%{evr}\t%{reponame}\n");
428 + query
429 + }
430 +
431 + /// Every installed package and its EVR.
432 + ///
433 + /// `rpm -qa` rather than `rpm -q <name>...` for the watchlist: querying names
434 + /// that are not installed makes rpm exit nonzero, which would turn "Firefox is
435 + /// not on this machine" into a failed check. Asking for everything always
436 + /// succeeds and costs one process.
437 + fn installed_query() -> Invocation {
438 + Invocation::new("rpm").args(["-qa", "--qf", "%{NAME}\t%{EVR}\n"])
439 + }
440 +
441 + /// Parse `rpm -qa` output into name -> EVR.
442 + ///
443 + /// Multiple arches of one package collapse to whichever comes last. That is a
444 + /// real ambiguity (an i686 and an x86_64 glibc differ in nothing this screen
445 + /// shows) and picking one is better than rendering the name twice.
446 + fn parse_installed(raw: &str) -> BTreeMap<String, String> {
447 + raw.lines()
448 + .filter_map(|line| line.split_once('\t'))
449 + .map(|(name, evr)| (name.trim().to_string(), evr.trim().to_string()))
450 + .collect()
451 + }
452 +
453 + /// Parse `dnf repoquery --upgrades` output, splitting watched from bulk.
454 + ///
455 + /// Deduplicated by name for the same reason [`parse_installed`] collapses
456 + /// arches: dnf lists one row per arch, and two rows for `glibc` reads as two
457 + /// problems.
458 + pub(crate) fn parse_upgrades(
459 + raw: &str,
460 + installed: &BTreeMap<String, String>,
461 + ) -> (Vec<Behind>, usize) {
462 + let mut available: BTreeMap<String, (String, String)> = BTreeMap::new();
463 +
464 + for line in raw.lines() {
465 + let mut fields = line.split('\t');
466 + let (Some(name), Some(evr)) = (fields.next(), fields.next()) else {
467 + continue;
468 + };
469 + let name = name.trim();
470 + if name.is_empty() {
471 + continue;
472 + }
473 + let repo = fields.next().unwrap_or("").trim().to_string();
474 + available.insert(name.to_string(), (evr.trim().to_string(), repo));
475 + }
476 +
477 + // Watchlist order, not alphabetical: WATCHLIST is a rough exposure ranking
478 + // and the browser belongs at the top of the screen.
479 + let watched: Vec<Behind> = WATCHLIST
480 + .iter()
481 + .filter_map(|name| {
482 + available.get(*name).map(|(evr, repo)| Behind {
483 + name: (*name).to_string(),
484 + installed: installed.get(*name).cloned(),
485 + available: evr.clone(),
486 + repo: repo.clone(),
487 + })
488 + })
489 + .collect();
490 +
491 + let bulk = available
492 + .keys()
493 + .filter(|name| !WATCHLIST.contains(&name.as_str()))
494 + .count();
495 +
496 + (watched, bulk)
497 + }
498 +
499 + /// Whether this machine can be checked at all.
500 + ///
Lines truncated