Skip to main content

max / alloy

Say on the update screen what the layer is, before bootc says it as an error Since cad26d5 every Alloy machine carries alloy and shop as layered packages for as long as it exists, and the console has not mentioned it. That is not a cosmetic gap: bootc upgrade, the verb bootc's own documentation names, refuses outright on a deployment with local rpm-ostree modifications and does nothing. A user reaching for it on the screen named `alloy update` gets an error about modifications they never made. c4b54dc measured that bootc switch does not consult them and works, so the machine has a path; nothing was telling anyone which one it is. The note sorts the layers rather than listing them. Which packages are ours is read from /usr/share/alloy/components, the file the Containerfile writes and alloy-layer-components.service installs from, so the server profile saying `alloy` alone makes this screen say `alloy` alone. Off an Alloy image the file is absent and the note attributes nothing at all, which is deliberate: a fallback pair of names would label somebody else's package as ours on a machine we did not build. NEVRA is matched as well as the bare name, since a component layered by hand is recorded under the string that installed it, and the version must start with a digit so alloy-utils is not read as alloy wearing a version. The bootc caution goes with any layer and the double-boot line only with ours. bootc reads local modifications and does not care who asked for them; the self-heal is ours, and telling a user their vim-enhanced will be reinstalled by a unit would be false. The third line is the same fact alloy-layer-notice puts on the console during the blank boot (3bdb777), moved to before the command is typed rather than during its consequence. A switch discards the layers, the unit lays the new image's copies down, and the machine boots twice. Words here and paint in the caller, the split Staleness::report already makes, for the reason it makes it: this copy claims things about what two commands do and a test can read a String. Ten tests over the shapes, two of them through the parse. Also: docs/STACK.md said `alloy update` reads requested-packages and drops the layers, which it does not do and does not need to on the switch path. It now says what actually happens today and leaves the hand drain where it belongs, with the network repo that will need it. The RPM_OSTREE fixture comment claimed an Alloy machine layers nothing, which stopped being true at the flip.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-15 19:32 UTC
Signed with PGP, not checked
Commit: f6d4b2083191460e030ef9141f887470cd9280d7
Parent: 3bdb777
3 files changed, +319 insertions, -15 deletions
@@ -83,6 +83,17 @@
83 83 versions, and a bare count for everything else. Merging them is the failure mode:
84 84 "247 behind" every week is a screen people learn to dismiss.
85 85
86 + It also says what the machine's layers are and what they cost, because since the
87 + component flip every Alloy machine carries `alloy` and `shop` as layers for as
88 + long as it exists. That has one consequence a user otherwise meets as an error
89 + message: `bootc upgrade` refuses outright on a machine with local modifications,
90 + and `bootc switch` is the verb that works. The note sorts the layers into ours
91 + and the user's own, reading which are ours from
92 + `/usr/share/alloy/components` rather than assuming, and off an Alloy image it
93 + attributes nothing. It also warns about the second boot: a switch discards the
94 + layers and the first-boot unit lays the new image's copies down, so adopting a
95 + rebuild boots twice. See docs/STACK.md, "Hotfixes".
96 +
86 97 Three properties of that check are constraints rather than choices. It is a
87 98 keypress, never a timer or a poll, because an installed machine reaching out on
88 99 its own would undo what `alloy@85ee0e6` established. It names each disabled
M docs/STACK.md +4 -2
@@ -44,9 +44,11 @@
44 44
45 45 That repo is enabled, and it is forced rather than chosen: `rpm-ostree install` on a booted system supports `--enablerepo` only in a container build, so a repo shipped disabled is one the first-boot unit could not turn on for its own transaction. It reaches no network, which is why this does not touch the position below.
46 46
47 - **Our layers are disposable, and `alloy update` is what makes them so.** A layered package whose version the base later overtakes wedges the machine: every subsequent update fails to depsolve, permanently, and that is the normal life of a hotfix rather than an edge case. The rule that avoids it is to carry no layer of ours across an upgrade. Drop them, upgrade, re-apply only if the base still lacks the fix. It costs one reboot rather than two, because the two transactions compose into one deployment, and it hands `bootc upgrade` back a deployment it will consent to work on.
47 + **Our layers are disposable.** A layered package whose version the base later overtakes wedges the machine: every subsequent update fails to depsolve, permanently, and that is the normal life of a hotfix rather than an edge case. The rule that avoids it is to carry no layer of ours across an upgrade. Drop them, upgrade, re-apply only if the base still lacks the fix. It costs one reboot rather than two, because the two transactions compose into one deployment, and it hands `bootc upgrade` back a deployment it will consent to work on.
48 48
49 - Dropping them has to be precise. A request is recorded under the string that was typed to install it, so a package layered by full name-version-release cannot be removed by its bare name, and rpm-ostree reports that as nothing-to-do rather than as a failure. `alloy update` reads `requested-packages` and removes exactly what it finds. Not `rpm-ostree reset`, which would also drop packages the user layered themselves, and those are not ours to remove.
49 + On the path Alloy takes today the rule costs nothing to apply, because `bootc switch` applies it for free. `switch` is not `upgrade`: only `upgrade` consults local modifications and refuses, and a switch discards the layer outright rather than carrying it. The machine comes up once with no console, `alloy-layer-components.service` sees no `/usr/bin/alloy` and lays the new image's copies down, and it reboots a second time into them. So a component tracks the image it shipped with, and nothing has to own the drop. The second boot is a blank screen on a machine that was working when its owner typed the command, so it says what it is doing: `alloy-layer-notice` writes to `/dev/console`, which `quiet` does not silence, and `alloy update` says the same thing before the command is typed.
50 +
51 + Dropping a layer by hand has to be precise, and that is what a network repo will need. A request is recorded under the string that was typed to install it, so a package layered by full name-version-release cannot be removed by its bare name, and rpm-ostree reports that as nothing-to-do rather than as a failure. Whatever drives `rpm-ostree upgrade` reads `requested-packages` and removes exactly what it finds. Not `rpm-ostree reset`, which would also drop packages the user layered themselves, and those are not ours to remove.
50 52
51 53 **This does not weaken the no-phone-home position above.** The repo is configured on an installed machine, and configured is not consulted: nothing fetches its metadata until somebody runs `alloy update`. A hotfix channel changes what a check can deliver, not whether one happens unasked.
52 54
@@ -1207,6 +1207,121 @@
1207 1207 }
1208 1208 }
1209 1209
1210 + /// Where the image names its own components, one per line.
1211 + ///
1212 + /// Written at build time rather than hardcoded here, because the profile
1213 + /// decides the list: `server` has no compositor and never lays shop down
1214 + /// (Containerfile, the components block). Reading it means a profile that ships
1215 + /// a different set does not make this screen lie about whose package is whose.
1216 + const COMPONENTS_PATH: &str = "/usr/share/alloy/components";
1217 +
1218 + /// Alloy's own components, as this image names them.
1219 + ///
1220 + /// `None` when the file is absent, which is not a failure and is not rare: a
1221 + /// development box, a Fedora ostree machine that is not Alloy, or an image
1222 + /// built before the component flip (GO alloy `d866e125`). The caller says less
1223 + /// in that case rather than falling back to a guess, because the guess here is
1224 + /// labelling somebody else's package as ours.
1225 + fn components() -> Option<Vec<String>> {
1226 + let raw = std::fs::read_to_string(COMPONENTS_PATH).ok()?;
1227 + let names: Vec<String> = raw.split_whitespace().map(str::to_string).collect();
1228 + (!names.is_empty()).then_some(names)
1229 + }
1230 +
1231 + /// Whether a layered entry is the component `name`.
1232 + ///
1233 + /// Two forms, because a request is recorded under the string that installed it.
1234 + /// `alloy-layer-components.service` installs by bare name, so that is what an
1235 + /// ordinary Alloy machine reports, and NEVRA is what a machine somebody layered
1236 + /// by hand reports. The version has to start with a digit for the second form
1237 + /// to match, so `alloy-utils` is not read as `alloy` wearing a version.
1238 + fn is_component(entry: &str, name: &str) -> bool {
1239 + entry == name
1240 + || entry
1241 + .strip_prefix(name)
1242 + .and_then(|rest| rest.strip_prefix('-'))
1243 + .is_some_and(|rest| rest.starts_with(|c: char| c.is_ascii_digit()))
1244 + }
1245 +
1246 + /// What the booted deployment's layering means, as words.
1247 + ///
1248 + /// Empty when nothing is layered, which is the answer on every non-Alloy ostree
1249 + /// machine and was the answer here too until the component flip. Since
1250 + /// `cad26d5` every Alloy machine carries `alloy` and `shop` as layers for as
1251 + /// long as it exists, and that has a consequence the user meets at the worst
1252 + /// possible moment otherwise: `bootc upgrade`, the verb bootc's own
1253 + /// documentation names, refuses outright on a machine with local modifications
1254 + /// and does nothing. Saying so on the screen that owns updates is cheaper than
1255 + /// letting them read the error.
1256 + ///
1257 + /// `ours` is `None` when the machine does not name its components; then the
1258 + /// list is printed unattributed rather than sorted into ours and theirs.
1259 + ///
1260 + /// Words here and paint in the caller, the same split
1261 + /// [`Staleness::report`](crate::stale::Staleness::report) makes, and for the
1262 + /// same reason: this copy makes claims about what two commands do, and a test
1263 + /// can read a `String`.
1264 + fn layer_note(layered: &[String], ours: Option<&[String]>) -> Vec<String> {
1265 + if layered.is_empty() {
1266 + return Vec::new();
1267 + }
1268 +
1269 + let mut lines = Vec::new();
1270 +
1271 + match ours {
1272 + Some(components) => {
1273 + let (mine, theirs): (Vec<&String>, Vec<&String>) = layered
1274 + .iter()
1275 + .partition(|entry| components.iter().any(|name| is_component(entry, name)));
1276 + let named = |set: &[&String]| {
1277 + set.iter()
1278 + .map(|entry| entry.as_str())
1279 + .collect::<Vec<_>>()
1280 + .join(", ")
1281 + };
1282 + lines.push(match (mine.is_empty(), theirs.is_empty()) {
1283 + (false, true) => format!("layered: {} (Alloy's own)", named(&mine)),
1284 + (false, false) => format!(
1285 + "layered: {} (Alloy's own), {} (yours)",
1286 + named(&mine),
1287 + named(&theirs)
1288 + ),
1289 + _ => format!("layered: {}", named(&theirs)),
1290 + });
1291 + }
1292 + None => {
1293 + lines.push(format!("layered: {}", layered.join(", ")));
1294 + }
1295 + }
1296 +
1297 + // The caution goes with any layer, not only ours: bootc reads local
1298 + // modifications and does not care who asked for them.
1299 + lines.push(
1300 + "bootc upgrade refuses while anything is layered. bootc switch does not, and is the path \
1301 + Alloy takes."
1302 + .to_string(),
1303 + );
1304 +
1305 + // Only when some of it is ours, because the self-heal is ours. A switch
1306 + // discards the layer outright, `alloy-layer-components.service` sees no
1307 + // /usr/bin/alloy and lays the new image's copies down, and that second
1308 + // reboot is the mechanism by which a rebuilt console reaches anyone.
1309 + // Measured 2026-08-15, build/layertest/README.md.
1310 + if ours.is_some_and(|components| {
1311 + layered
1312 + .iter()
1313 + .any(|entry| components.iter().any(|name| is_component(entry, name)))
1314 + }) {
1315 + lines.push(
1316 + "a switch discards these and the next boot lays the new image's copies down, so the \
1317 + machine boots twice."
1318 + .to_string(),
1319 + );
1320 + }
1321 +
1322 + lines
1323 + }
1324 +
1210 1325 /// Reads `rpm-ostree status --json`.
1211 1326 ///
1212 1327 /// Deliberately not a [`Backend`]: those answer for containers and yield [`Box`]
@@ -1327,6 +1442,10 @@
1327 1442 /// Whether dnf is on this machine, probed once. Guards the staleness check
1328 1443 /// rather than the tab: a machine without dnf can still show deployments.
1329 1444 has_dnf: bool,
1445 + /// Alloy's own components, read once at construction. `None` off an Alloy
1446 + /// image. A file read, and the file is part of `/usr` on a read-only ostree
1447 + /// tree, so it cannot change while this view is up.
1448 + components: Option<Vec<String>>,
1330 1449 /// The last staleness check, or the error from trying. `None` until the user
1331 1450 /// presses `u`, and it stays `None` otherwise — that is the pull-not-push
1332 1451 /// constraint, and [`refresh`] deliberately does not touch it.
@@ -1363,6 +1482,7 @@
1363 1482 has_rpm: Rpm::present(),
1364 1483 rpm: None,
1365 1484 has_dnf: stale::available(),
1485 + components: components(),
1366 1486 stale: None,
1367 1487 last_checked: stale::last_checked(),
1368 1488 };
@@ -1765,11 +1885,21 @@
1765 1885 // The deployments take exactly the rows they need and the staleness
1766 1886 // block takes the rest. The other way round — deployments on `Min` —
1767 1887 // would grow a two-row list into half the screen and push the thing the
1768 - // user pressed a key for off the bottom.
1888 + // user pressed a key for off the bottom. The layer note sizes to itself
1889 + // for the same reason, and is zero rows on a machine with no layer.
1769 1890 let deployment_rows = u16::try_from(status.deployments.len().max(1)).unwrap_or(u16::MAX);
1770 - let [list_area, summary_area, _gap, stale_area] = Layout::vertical([
1891 + let note = status
1892 + .booted()
1893 + .map(|dep| layer_note(dep.layered(), self.components.as_deref()))
1894 + .unwrap_or_default();
1895 + let note_rows = u16::try_from(note.len()).unwrap_or(u16::MAX);
1896 + // The note sits with the summary rather than with the staleness block:
1897 + // both answer for the machine as it is now, and staleness answers for
1898 + // what a rebuild would change.
1899 + let [list_area, summary_area, layer_area, _gap, stale_area] = Layout::vertical([
1771 1900 Constraint::Length(deployment_rows),
1772 1901 Constraint::Length(1),
1902 + Constraint::Length(note_rows),
1773 1903 Constraint::Length(1),
1774 1904 Constraint::Min(0),
1775 1905 ])
@@ -1801,6 +1931,14 @@
1801 1931 frame.render_widget(Line::from(text::muted(theme, summary)), summary_area);
1802 1932 }
1803 1933
1934 + if !note.is_empty() {
1935 + let lines: Vec<Line> = note
1936 + .iter()
1937 + .map(|line| Line::from(text::muted(theme, line.clone())))
1938 + .collect();
1939 + frame.render_widget(AlloyList::new(theme, lines), layer_area);
1940 + }
1941 +
1804 1942 self.render_staleness(frame, stale_area, theme);
1805 1943 }
1806 1944
@@ -2210,9 +2348,14 @@
2210 2348 // `rpm-ostree status --json` on the installed system. The `base-commit-meta`
2211 2349 // manifest blob (a 38 KB embedded OCI manifest) and the null `transaction` /
2212 2350 // `cached-update` / `update-driver` keys are elided; the parser reads none of
2213 - // them. This is the whole of a stock Alloy deployment: one booted
2214 - // container-image deployment with nothing layered, which is the state the
2215 - // installed tab exists to show.
2351 + // them. One booted container-image deployment with nothing layered.
2352 + //
2353 + // It predates the component flip (`cad26d5`), and is kept rather than
2354 + // recaptured: an Alloy machine now carries `alloy` and `shop` as layers for
2355 + // as long as it exists, so this is no longer what one looks like. What it is
2356 + // is the unlayered case, which the parser still has to read and which every
2357 + // non-Alloy ostree machine is in. `RPM_OSTREE_ALLOY` below is the current
2358 + // shape.
2216 2359 const RPM_OSTREE: &str = r#"{
2217 2360 "deployments": [
2218 2361 {
@@ -2230,14 +2373,13 @@
2230 2373 ]
2231 2374 }"#;
2232 2375
2233 - // Not a real Alloy capture: a stock image layers nothing, so this state does
2234 - // not occur on Alloy. Hand-authored to rpm-ostree's documented schema (the
2235 - // field shapes match the real capture above) to exercise the cases the
2376 + // Not a real Alloy capture. Hand-authored to rpm-ostree's documented schema
2377 + // (the field shapes match the real capture above) to exercise the cases the
2236 2378 // pristine one cannot — a booted deployment with layered packages, a staged
2237 2379 // change pending underneath it, and a classic ostree-origin rollback target
2238 - // with no container reference. It is what a machine someone had run
2239 - // `rpm-ostree install` on would report, which is exactly the exception the
2240 - // installed tab is for.
2380 + // with no container reference. Everything layered here is a user's own, and
2381 + // none of it is a component, which is the half of the layer note that says
2382 + // "yours".
2241 2383 const RPM_OSTREE_LAYERED: &str = r#"{
2242 2384 "deployments": [
2243 2385 {
@@ -2268,6 +2410,27 @@
2268 2410 ]
2269 2411 }"#;
2270 2412
2413 + // What an Alloy machine has reported since the component flip: the two
2414 + // components layered by bare name, which is the string
2415 + // `alloy-layer-components.service` installs them under, over a base image
2416 + // that carries neither binary. Hand-authored on the shape of the capture
2417 + // above rather than captured, because the machine it would be captured from
2418 + // has no console until it has already layered.
2419 + const RPM_OSTREE_ALLOY: &str = r#"{
2420 + "deployments": [
2421 + {
2422 + "booted": true,
2423 + "staged": false,
2424 + "pinned": false,
2425 + "checksum": "4444444444444444444444444444444444444444444444444444444444444444",
2426 + "container-image-reference": "ostree-unverified-registry:localhost/alloy:local",
2427 + "version": "43.20260815.0",
2428 + "requested-packages": ["alloy", "shop"],
2429 + "packages": ["alloy", "shop"]
2430 + }
2431 + ]
2432 + }"#;
2433 +
2271 2434 /// A spec declaring each name at a level, with no source fields.
2272 2435 ///
2273 2436 /// Enough for the parsers, which only ask the spec for a level and a
@@ -3084,6 +3247,9 @@
3084 3247 // `false` also pins the unchecked-state copy, since a fixture that
3085 3248 // claimed dnf would offer a key these tests cannot press.
3086 3249 has_dnf: false,
3250 + // Not read from the host either. The layer note is a pure function
3251 + // over this and the booted deployment, and is tested as one.
3252 + components: None,
3087 3253 stale: None,
3088 3254 // Not read from disk: a fixture that picked up this machine's real
3089 3255 // state file would render a different line depending on when the
@@ -3771,8 +3937,9 @@
3771 3937 );
3772 3938 }
3773 3939
3774 - // The whole point of the installed tab: a stock image layers nothing, and
3775 - // that empty answer is correct rather than a failure to read.
3940 + // An unlayered machine reads as unlayered rather than as a failed read. Not
3941 + // an Alloy machine any more (see the fixture), and still the state every
3942 + // other ostree machine is in.
3776 3943 #[test]
3777 3944 fn a_stock_image_has_no_layered_packages() {
3778 3945 let status = Rpm::parse(RPM_OSTREE).unwrap();
@@ -3837,6 +4004,130 @@
3837 4004 assert!(status.booted().is_none());
3838 4005 }
3839 4006
4007 + // ---- the layer note ----
4008 +
4009 + // The components file is what the image says, and this is the ordinary
4010 + // Alloy machine: both of ours layered and nothing else.
4011 + #[test]
4012 + fn an_alloy_machine_is_told_which_layers_are_alloys() {
4013 + let ours = ["alloy".to_string(), "shop".to_string()];
4014 + let note = layer_note(&ours, Some(&ours));
4015 + assert_eq!(note[0], "layered: alloy, shop (Alloy's own)");
4016 + assert_eq!(note.len(), 3, "composition, the caution, the double boot");
4017 + }
4018 +
4019 + // A user's own layers are named as theirs. Sorting them into the wrong half
4020 + // would tell somebody a package they installed came with the machine.
4021 + #[test]
4022 + fn a_users_own_layers_are_kept_apart_from_ours() {
4023 + let layered = [
4024 + "alloy".to_string(),
4025 + "vim-enhanced".to_string(),
4026 + "shop".to_string(),
4027 + ];
4028 + let ours = ["alloy".to_string(), "shop".to_string()];
4029 + let note = layer_note(&layered, Some(&ours));
4030 + assert_eq!(
4031 + note[0],
4032 + "layered: alloy, shop (Alloy's own), vim-enhanced (yours)"
4033 + );
4034 + }
4035 +
4036 + // Nothing of ours layered: the bootc caution still applies, because bootc
4037 + // reads local modifications and does not care who asked for them. The
4038 + // double-boot line does not, because the self-heal is ours.
4039 + #[test]
4040 + fn layers_that_are_none_of_ours_get_the_caution_and_not_the_self_heal() {
4041 + let ours = ["alloy".to_string(), "shop".to_string()];
4042 + let note = layer_note(&["vim-enhanced".to_string()], Some(&ours));
4043 + assert_eq!(note.len(), 2);
4044 + assert_eq!(note[0], "layered: vim-enhanced");
4045 + assert!(note[1].contains("bootc upgrade refuses"));
4046 + }
4047 +
4048 + // Off an Alloy image there is no components file, and the note attributes
4049 + // nothing rather than guessing. A guess here labels somebody else's package
4050 + // as ours on a machine we did not build.
4051 + #[test]
4052 + fn a_machine_that_names_no_components_attributes_nothing() {
4053 + let note = layer_note(&["vim-enhanced".to_string(), "tmux".to_string()], None);
4054 + assert_eq!(note.len(), 2);
4055 + assert_eq!(note[0], "layered: vim-enhanced, tmux");
4056 + assert!(
4057 + !note.iter().any(|line| line.contains("Alloy's own")),
4058 + "nothing may be claimed as ours without the file that says so: {note:?}"
4059 + );
4060 + }
4061 +
4062 + // End to end over the parse, which is how the view reaches this: the booted
4063 + // deployment of a real Alloy machine, against the components its image
4064 + // names.
4065 + #[test]
4066 + fn an_alloy_deployment_reads_through_to_the_note() {
4067 + let status = Rpm::parse(RPM_OSTREE_ALLOY).unwrap();
4068 + let ours = ["alloy".to_string(), "shop".to_string()];
4069 + let note = layer_note(status.booted().unwrap().layered(), Some(&ours));
4070 + assert_eq!(note[0], "layered: alloy, shop (Alloy's own)");
4071 + assert!(note[2].contains("boots twice"));
4072 + }
4073 +
4074 + // And the machine the note is not for: an ostree machine that is not Alloy,
4075 + // layering only its own packages, gets the caution and no claim about a
4076 + // console it does not have.
4077 + #[test]
4078 + fn a_machine_layering_only_its_own_packages_hears_nothing_about_components() {
4079 + let status = Rpm::parse(RPM_OSTREE_LAYERED).unwrap();
4080 + let ours = ["alloy".to_string(), "shop".to_string()];
4081 + let note = layer_note(status.booted().unwrap().layered(), Some(&ours));
4082 + assert_eq!(note[0], "layered: vim-enhanced, rsync");
4083 + assert_eq!(note.len(), 2);
4084 + }
4085 +
4086 + // The state this note does not exist for. A machine with no layer hears
4087 + // nothing about bootc's two verbs, because for it they behave the same.
4088 + #[test]
4089 + fn an_unlayered_machine_gets_no_note() {
4090 + assert!(layer_note(&[], Some(&["alloy".to_string()])).is_empty());
4091 + }
4092 +
4093 + // The whole reason the note exists: `bootc upgrade` is the verb bootc's own
4094 + // documentation names and the one that no longer works here, and `bootc
4095 + // switch` is the one that does. Both measured, 2026-08-15,
4096 + // build/layertest/README.md. If this assertion is ever "fixed" onto
4097 + // `rpm-ostree upgrade`, read that file first.
4098 + #[test]
4099 + fn the_caution_names_both_verbs_and_which_one_refuses() {
4100 + let ours = ["alloy".to_string()];
4101 + let note = layer_note(&ours, Some(&ours));
4102 + let caution = note.iter().find(|line| line.contains("bootc")).unwrap();
4103 + assert!(caution.contains("bootc upgrade refuses"));
4104 + assert!(caution.contains("bootc switch does not"));
4105 + }
4106 +
4107 + // A hand-layered component is recorded under the NEVRA that installed it,
4108 + // and is still ours. `alloy-layer-components.service` installs by bare name
4109 + // so this is not the ordinary shape, but a machine somebody fixed by hand
4110 + // reports it and should not be told its console is somebody else's package.
4111 + #[test]
4112 + fn a_component_layered_by_nevra_is_still_ours() {
4113 + let note = layer_note(
4114 + &["alloy-0.1.0-1.fc43.x86_64".to_string()],
4115 + Some(&["alloy".to_string()]),
4116 + );
4117 + assert_eq!(note[0], "layered: alloy-0.1.0-1.fc43.x86_64 (Alloy's own)");
4118 + }
4119 +
4120 + // The near-miss the NEVRA match has to refuse: a package whose name merely
4121 + // starts with ours is not ours.
4122 + #[test]
4123 + fn a_package_named_after_ours_is_not_ours() {
4124 + assert!(is_component("alloy-0.1.0-1.fc43.x86_64", "alloy"));
4125 + assert!(is_component("alloy", "alloy"));
4126 + assert!(!is_component("alloy-utils", "alloy"));
4127 + assert!(!is_component("alloys", "alloy"));
4128 + assert!(!is_component("shop", "alloy"));
4129 + }
4130 +
3840 4131 /// Parse this machine's real `rpm-ostree status --json`.
3841 4132 ///
3842 4133 /// Ignored by default: needs an ostree system, which the dev box is not. Run