max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
1 file changed,
+156 insertions,
-13 deletions
| @@ -216,6 +216,45 @@ | |||
| 216 | 216 | /// is what says *which* declared box this is, and that is what lets the view | |
| 217 | 217 | /// work out which declared boxes have no row at all. | |
| 218 | 218 | pub declared: Option<String>, | |
| 219 | + | /// Which flatpak installation the app lives in. | |
| 220 | + | /// | |
| 221 | + | /// Flatpak-only, and `None` everywhere else: podman has one place to put a | |
| 222 | + | /// container and rpm-ostree is not a backend here. It is carried on the row | |
| 223 | + | /// rather than assumed at removal time because the two installations hold | |
| 224 | + | /// different sets of apps, and `flatpak uninstall` addressed at the wrong | |
| 225 | + | /// one reports the app as not installed while it is sitting on the screen. | |
| 226 | + | pub scope: Option<Scope>, | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | /// A flatpak installation: the per-user one, or the system-wide one. | |
| 230 | + | /// | |
| 231 | + | /// Flatpak also supports named custom installations, which parse to `None` | |
| 232 | + | /// rather than to a member here. Alloy neither creates them nor knows their | |
| 233 | + | /// names, and inventing a third member would mean guessing a flag for one. | |
| 234 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 235 | + | pub(crate) enum Scope { | |
| 236 | + | User, | |
| 237 | + | System, | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | impl Scope { | |
| 241 | + | /// The flag that addresses this installation. | |
| 242 | + | const fn flag(self) -> &'static str { | |
| 243 | + | match self { | |
| 244 | + | Scope::User => "--user", | |
| 245 | + | Scope::System => "--system", | |
| 246 | + | } | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | /// Flatpak's own word for an installation, as the `installation` column | |
| 250 | + | /// prints it. | |
| 251 | + | fn parse(word: &str) -> Option<Self> { | |
| 252 | + | match word.trim() { | |
| 253 | + | "user" => Some(Scope::User), | |
| 254 | + | "system" => Some(Scope::System), | |
| 255 | + | _ => None, | |
| 256 | + | } | |
| 257 | + | } | |
| 219 | 258 | } | |
| 220 | 259 | ||
| 221 | 260 | impl Box { | |
| @@ -247,6 +286,10 @@ | |||
| 247 | 286 | .to_string(), | |
| 248 | 287 | state: BoxState::Absent, | |
| 249 | 288 | declared: Some(name.to_string()), | |
| 289 | + | // Nothing is installed, so there is no installation to name. A | |
| 290 | + | // sandboxed box gets one when it is created, and Alloy creates | |
| 291 | + | // them per-user. | |
| 292 | + | scope: None, | |
| 250 | 293 | } | |
| 251 | 294 | } | |
| 252 | 295 | } | |
| @@ -528,6 +571,7 @@ | |||
| 528 | 571 | state: podman_state(&row.state), | |
| 529 | 572 | source: row.image, | |
| 530 | 573 | name, | |
| 574 | + | scope: None, | |
| 531 | 575 | } | |
| 532 | 576 | }) | |
| 533 | 577 | .collect(); | |
| @@ -886,7 +930,12 @@ | |||
| 886 | 930 | /// contract here is `--columns`, which is documented and stable, against a | |
| 887 | 931 | /// tab-separated body. Requesting explicit columns rather than parsing the | |
| 888 | 932 | /// default table is what makes it a contract instead of a layout. | |
| 889 | - | const FLATPAK_COLUMNS: &str = "application,name,origin"; | |
| 933 | + | /// | |
| 934 | + | /// `installation` is the last of them and is not cosmetic: it is what | |
| 935 | + | /// [`Backend::remove`] addresses. Nothing displays it, on the grounds that the | |
| 936 | + | /// user picked a level and the installation is how Alloy honors it, not a | |
| 937 | + | /// second dial. | |
| 938 | + | const FLATPAK_COLUMNS: &str = "application,name,origin,installation"; | |
| 890 | 939 | ||
| 891 | 940 | impl Backend for Flatpak { | |
| 892 | 941 | fn name(&self) -> &'static str { | |
| @@ -915,6 +964,8 @@ | |||
| 915 | 964 | return None; | |
| 916 | 965 | } | |
| 917 | 966 | let name = fields.next().unwrap_or("").trim(); | |
| 967 | + | let _origin = fields.next(); | |
| 968 | + | let scope = fields.next().and_then(Scope::parse); | |
| 918 | 969 | Some(Box { | |
| 919 | 970 | // A sandboxed box contains exactly one app, so the app is | |
| 920 | 971 | // the box. Its human name is the identity a user recognizes; | |
| @@ -933,6 +984,7 @@ | |||
| 933 | 984 | // spec is keyed by the box name, so this goes through the | |
| 934 | 985 | // app index rather than a direct lookup. | |
| 935 | 986 | declared: spec.declared_name(app), | |
| 987 | + | scope, | |
| 936 | 988 | }) | |
| 937 | 989 | }) | |
| 938 | 990 | .collect(); | |
| @@ -940,15 +992,25 @@ | |||
| 940 | 992 | Ok(boxes) | |
| 941 | 993 | } | |
| 942 | 994 | ||
| 943 | - | /// `flatpak install`, from the declared remote when the spec names one. | |
| 995 | + | /// `flatpak install --user`, from the declared remote when the spec names | |
| 996 | + | /// one. | |
| 944 | 997 | /// | |
| 945 | 998 | /// Without a remote flatpak resolves the app across the ones configured, | |
| 946 | 999 | /// which is what should happen when only one carries it and an error worth | |
| 947 | 1000 | /// seeing when several do. `--noninteractive` because the console has handed | |
| 948 | 1001 | /// flatpak no terminal to ask on. | |
| 1002 | + | /// | |
| 1003 | + | /// `--user` is the load-bearing flag and pairs with that one. Flatpak's | |
| 1004 | + | /// default installation is the system one, which is a polkit action, and a | |
| 1005 | + | /// non-interactive install with no agent registered fails on authorization | |
| 1006 | + | /// rather than asking — with nowhere to ask, since the console is holding | |
| 1007 | + | /// the terminal. A per-user install needs no privilege at all, which is the | |
| 1008 | + | /// answer this level should give anyway: `sandboxed` is one user's app, and | |
| 1009 | + | /// nothing about it wants to be installed for every account on the machine. | |
| 949 | 1010 | fn create(&self, name: &str, spec: &SpecBox) -> Result<Invocation> { | |
| 950 | 1011 | let app = spec.app(name)?; | |
| 951 | - | let mut invocation = Invocation::new("flatpak").args(["install", "--noninteractive"]); | |
| 1012 | + | let mut invocation = | |
| 1013 | + | Invocation::new("flatpak").args(["install", Scope::User.flag(), "--noninteractive"]); | |
| 952 | 1014 | if let Some(remote) = &spec.remote { | |
| 953 | 1015 | invocation = invocation.arg(remote); | |
| 954 | 1016 | } | |
| @@ -978,8 +1040,21 @@ | |||
| 978 | 1040 | None | |
| 979 | 1041 | } | |
| 980 | 1042 | ||
| 1043 | + | /// `flatpak uninstall`, addressed at the installation the row was found in. | |
| 1044 | + | /// | |
| 1045 | + | /// An app in the user installation and an app in the system one are removed | |
| 1046 | + | /// by different commands, and flatpak resolves a bare `uninstall` against | |
| 1047 | + | /// the system installation first. Alloy installs per-user, so a bare | |
| 1048 | + | /// uninstall of an Alloy-created box asks the wrong installation and is told | |
| 1049 | + | /// the app is not installed. An unrecognized installation (a named custom | |
| 1050 | + | /// one) drops the flag and lets flatpak resolve it, which is the same | |
| 1051 | + | /// behaviour this had before the column was read. | |
| 981 | 1052 | fn remove(&self, boxed: &Box) -> Invocation { | |
| 982 | - | Invocation::new("flatpak").args(["uninstall", &boxed.source]) | |
| 1053 | + | let mut invocation = Invocation::new("flatpak").arg("uninstall"); | |
| 1054 | + | if let Some(scope) = boxed.scope { | |
| 1055 | + | invocation = invocation.arg(scope.flag()); | |
| 1056 | + | } | |
| 1057 | + | invocation.arg(&boxed.source) | |
| 983 | 1058 | } | |
| 984 | 1059 | ||
| 985 | 1060 | /// A shell inside the app's sandbox, which is what "enter" means at this | |
| @@ -1990,10 +2065,13 @@ | |||
| 1990 | 2065 | } | |
| 1991 | 2066 | ]"#; | |
| 1992 | 2067 | ||
| 1993 | - | // Captured from `flatpak list --app --columns=application,name,origin`. | |
| 1994 | - | // Tab-separated, and the app id is the first field. | |
| 1995 | - | const FLATPAK: &str = "org.chromium.Chromium\tChromium Web Browser\tflathub\n\ | |
| 1996 | - | dev.edfloreshz.Tasks\tTasks\tflathub\n"; | |
| 2068 | + | // Captured from | |
| 2069 | + | // `flatpak list --app --columns=application,name,origin,installation`. | |
| 2070 | + | // Tab-separated, and the app id is the first field. The two rows differ in | |
| 2071 | + | // installation on purpose: that is the field `remove` addresses, and a | |
| 2072 | + | // fixture where every row agrees cannot show it being read. | |
| 2073 | + | const FLATPAK: &str = "org.chromium.Chromium\tChromium Web Browser\tflathub\tuser\n\ | |
| 2074 | + | dev.edfloreshz.Tasks\tTasks\tflathub\tsystem\n"; | |
| 1997 | 2075 | ||
| 1998 | 2076 | // Captured from a real `alloy install` booted in QEMU, 2026-07-22, by running | |
| 1999 | 2077 | // `rpm-ostree status --json` on the installed system. The `base-commit-meta` | |
| @@ -2210,11 +2288,70 @@ | |||
| 2210 | 2288 | #[test] | |
| 2211 | 2289 | fn a_flatpak_row_with_a_missing_name_falls_back_to_the_app_id() { | |
| 2212 | 2290 | let boxes = Flatpak | |
| 2213 | - | .parse("org.example.Thing\t\tflathub\n", &Spec::default()) | |
| 2291 | + | .parse("org.example.Thing\t\tflathub\tuser\n", &Spec::default()) | |
| 2214 | 2292 | .unwrap(); | |
| 2215 | 2293 | assert_eq!(boxes[0].name, "org.example.Thing"); | |
| 2216 | 2294 | } | |
| 2217 | 2295 | ||
| 2296 | + | // The installation a row was found in decides which command removes it, so | |
| 2297 | + | // the two rows of the fixture must not come out of the parser alike. | |
| 2298 | + | #[test] | |
| 2299 | + | fn a_sandboxed_row_carries_the_installation_it_was_found_in() { | |
| 2300 | + | let boxes = Flatpak.parse(FLATPAK, &Spec::default()).unwrap(); | |
| 2301 | + | let chromium = boxes | |
| 2302 | + | .iter() | |
| 2303 | + | .find(|b| b.source == "org.chromium.Chromium") | |
| 2304 | + | .unwrap(); | |
| 2305 | + | let tasks = boxes | |
| 2306 | + | .iter() | |
| 2307 | + | .find(|b| b.source == "dev.edfloreshz.Tasks") | |
| 2308 | + | .unwrap(); | |
| 2309 | + | assert_eq!(chromium.scope, Some(Scope::User)); | |
| 2310 | + | assert_eq!(tasks.scope, Some(Scope::System)); | |
| 2311 | + | assert_eq!( | |
| 2312 | + | Flatpak.remove(tasks).display(), | |
| 2313 | + | "flatpak uninstall --system dev.edfloreshz.Tasks", | |
| 2314 | + | "a system app is removed from the system installation, not the user one" | |
| 2315 | + | ); | |
| 2316 | + | } | |
| 2317 | + | ||
| 2318 | + | // Flatpak supports named custom installations. Alloy does not make them and | |
| 2319 | + | // has no flag for one, so the row keeps no scope and removal resolves the | |
| 2320 | + | // way it did before the column was read, rather than guessing at `--user`. | |
| 2321 | + | #[test] | |
| 2322 | + | fn an_unrecognized_installation_leaves_removal_unqualified() { | |
| 2323 | + | let boxes = Flatpak | |
| 2324 | + | .parse( | |
| 2325 | + | "org.example.Thing\tThing\tflathub\tmy-ssd\n", | |
| 2326 | + | &Spec::default(), | |
| 2327 | + | ) | |
| 2328 | + | .unwrap(); | |
| 2329 | + | assert_eq!(boxes[0].scope, None); | |
| 2330 | + | assert_eq!( | |
| 2331 | + | Flatpak.remove(&boxes[0]).display(), | |
| 2332 | + | "flatpak uninstall org.example.Thing" | |
| 2333 | + | ); | |
| 2334 | + | } | |
| 2335 | + | ||
| 2336 | + | // The bug this pairs with: `flatpak install` defaults to the system | |
| 2337 | + | // installation, which is a polkit action, and the console has already taken | |
| 2338 | + | // the terminal an agent would prompt on. Asserted over both spec shapes | |
| 2339 | + | // because the remote is what varies between them and the flag is what must | |
| 2340 | + | // not. | |
| 2341 | + | #[test] | |
| 2342 | + | fn a_sandboxed_box_is_never_installed_system_wide() { | |
| 2343 | + | let with_remote = creation_spec(); | |
| 2344 | + | let without = | |
| 2345 | + | Spec::parse("[box.thing]\nlevel = \"sandboxed\"\napp = \"org.example.T\"\n").unwrap(); | |
| 2346 | + | for argv in [ | |
| 2347 | + | create_argv(&Flatpak, &with_remote, "chromium"), | |
| 2348 | + | create_argv(&Flatpak, &without, "thing"), | |
| 2349 | + | ] { | |
| 2350 | + | assert!(argv.contains(" --user "), "got: {argv}"); | |
| 2351 | + | assert!(!argv.contains("--system"), "got: {argv}"); | |
| 2352 | + | } | |
| 2353 | + | } | |
| 2354 | + | ||
| 2218 | 2355 | // `flatpak list` on a machine with no apps prints nothing at all, which must | |
| 2219 | 2356 | // not read as a parse failure. | |
| 2220 | 2357 | #[test] | |
| @@ -2302,11 +2439,11 @@ | |||
| 2302 | 2439 | ||
| 2303 | 2440 | assert_eq!( | |
| 2304 | 2441 | Flatpak.list().display(), | |
| 2305 | - | "flatpak list --app --columns=application,name,origin" | |
| 2442 | + | "flatpak list --app --columns=application,name,origin,installation" | |
| 2306 | 2443 | ); | |
| 2307 | 2444 | assert_eq!( | |
| 2308 | 2445 | Flatpak.remove(chromium).display(), | |
| 2309 | - | "flatpak uninstall org.chromium.Chromium" | |
| 2446 | + | "flatpak uninstall --user org.chromium.Chromium" | |
| 2310 | 2447 | ); | |
| 2311 | 2448 | assert_eq!( | |
| 2312 | 2449 | Flatpak.enter(chromium).display(), | |
| @@ -2560,7 +2697,7 @@ | |||
| 2560 | 2697 | let spec = creation_spec(); | |
| 2561 | 2698 | assert_eq!( | |
| 2562 | 2699 | create_argv(&Flatpak, &spec, "chromium"), | |
| 2563 | - | "flatpak install --noninteractive flathub org.chromium.Chromium" | |
| 2700 | + | "flatpak install --user --noninteractive flathub org.chromium.Chromium" | |
| 2564 | 2701 | ); | |
| 2565 | 2702 | } | |
| 2566 | 2703 | ||
| @@ -2571,7 +2708,7 @@ | |||
| 2571 | 2708 | .unwrap(); | |
| 2572 | 2709 | assert_eq!( | |
| 2573 | 2710 | create_argv(&Flatpak, &spec, "thing"), | |
| 2574 | - | "flatpak install --noninteractive org.example.Thing" | |
| 2711 | + | "flatpak install --user --noninteractive org.example.Thing" | |
| 2575 | 2712 | ); | |
| 2576 | 2713 | } | |
| 2577 | 2714 | ||
| @@ -2747,6 +2884,7 @@ | |||
| 2747 | 2884 | source: "img".into(), | |
| 2748 | 2885 | state: BoxState::Running, | |
| 2749 | 2886 | declared: None, | |
| 2887 | + | scope: None, | |
| 2750 | 2888 | }, | |
| 2751 | 2889 | Box { | |
| 2752 | 2890 | name: "alpha".into(), | |
| @@ -2754,6 +2892,7 @@ | |||
| 2754 | 2892 | source: "img".into(), | |
| 2755 | 2893 | state: BoxState::Running, | |
| 2756 | 2894 | declared: None, | |
| 2895 | + | scope: None, | |
| 2757 | 2896 | }, | |
| 2758 | 2897 | Box { | |
| 2759 | 2898 | name: "yak".into(), | |
| @@ -2761,6 +2900,7 @@ | |||
| 2761 | 2900 | source: "img".into(), | |
| 2762 | 2901 | state: BoxState::Running, | |
| 2763 | 2902 | declared: Some("yak".into()), | |
| 2903 | + | scope: None, | |
| 2764 | 2904 | }, | |
| 2765 | 2905 | ]; | |
| 2766 | 2906 | sort_boxes(&mut boxes); | |
| @@ -2825,6 +2965,7 @@ | |||
| 2825 | 2965 | source: "fedora-toolbox:43".into(), | |
| 2826 | 2966 | state: BoxState::Running, | |
| 2827 | 2967 | declared: Some("dev".into()), | |
| 2968 | + | scope: None, | |
| 2828 | 2969 | }, | |
| 2829 | 2970 | Box { | |
| 2830 | 2971 | name: "stray".into(), | |
| @@ -2832,6 +2973,7 @@ | |||
| 2832 | 2973 | source: "alpine".into(), | |
| 2833 | 2974 | state: BoxState::Other("exited".into()), | |
| 2834 | 2975 | declared: None, | |
| 2976 | + | scope: None, | |
| 2835 | 2977 | }, | |
| 2836 | 2978 | ] | |
| 2837 | 2979 | } | |
| @@ -3260,6 +3402,7 @@ | |||
| 3260 | 3402 | source: String::new(), | |
| 3261 | 3403 | state: BoxState::Running, | |
| 3262 | 3404 | declared: Some(name.to_string()), | |
| 3405 | + | scope: None, | |
| 3263 | 3406 | }) | |
| 3264 | 3407 | .run(&mut log) | |
| 3265 | 3408 | .unwrap(); |