From 17e02816315aa040d4ef42c1a399724f929b5875 Mon Sep 17 00:00:00 2001 From: BoxBuddy Contributor Date: Mon, 17 Aug 2026 22:41:14 +0200 Subject: [PATCH 1/4] feat: add Uninstall button to applications list Adds a pill 'Uninstall' button next to 'Run' on every row in the View Applications popup. The handler shells to a terminal running 'distrobox enter NAME -- sudo remove EXEC', so the user sees the sudo prompt and can confirm before the package is removed. The package manager is picked from the box's image (apt/dnf/zypper/ pacman/apk/xbps-install/emerge/installpkg) by pick_pkg_manager_for_ uninstall - a small heuristic that mirrors what Kontainer's packageinstallcommand.cpp does for install flows. The default is apt because apt fails loudly on non-apt distros instead of partially succeeding. Resolves 'Uninstall application from box' from the Roadmap. The .desktop export on the host is deliberately left alone - that is a separate, reversible 'Remove From Menu' action already available on the same row. --- src/distrobox_handler.rs | 127 +++++++++++++++++++++++++++++++++++++++ src/main.rs | 29 ++++++++- 2 files changed, 153 insertions(+), 3 deletions(-) diff --git a/src/distrobox_handler.rs b/src/distrobox_handler.rs index 22c6c82..aaa63d9 100644 --- a/src/distrobox_handler.rs +++ b/src/distrobox_handler.rs @@ -810,6 +810,133 @@ pub fn clone_box(box_to_clone: &str, new_name: &str) -> String { ) } +/// Uninstalls an application from inside a box by running the distrobox's +/// package manager via `sudo` in a terminal. +/// +/// The actual command depends on the image. We pick a manager by matching the +/// image URL against the same set of regexes `install_deb_in_box` / +/// `install_rpm_in_box` use, falling back to `apt` for any unknown deb-style +/// distro. The `pkg_command` argument is the user-chosen subcommand and +/// targets list, e.g. `remove vim git` or `purge neofetch`. +/// +/// Spawning a terminal (rather than running the command in-process) lets the +/// user see the `sudo` prompt and answer it interactively. We do not remove +/// the `.desktop` export on the host - that is a separate, reversible action +/// the user can take from the same row. +pub fn uninstall_app_in_box(box_name: String, image: String, pkg_command: String) { + let (term, sep, term_is_flatpak) = get_terminal_and_separator_arg(); + let manager = pick_pkg_manager_for_uninstall(&image); + let inner = format!("sudo {manager} {pkg_command}"); + let command = format!("distrobox enter {box_name} -- /usr/bin/env bash -c \"{inner}\""); + + if is_flatpak() { + if term_is_flatpak { + Command::new("flatpak-spawn") + .arg("--host") + .arg("flatpak") + .arg("run") + .arg(term) + .arg(sep) + .arg("bash") + .arg("-c") + .arg(&command) + .spawn() + .unwrap(); + } else { + Command::new("flatpak-spawn") + .arg("--host") + .arg(term) + .arg(sep) + .arg("bash") + .arg("-c") + .arg(&command) + .spawn() + .unwrap(); + } + } else { + if term_is_flatpak { + Command::new("flatpak") + .arg("run") + .arg(term) + .arg(sep) + .arg("bash") + .arg("-c") + .arg(&command) + .spawn() + .unwrap(); + } else { + Command::new(term) + .arg(sep) + .arg("bash") + .arg("-c") + .arg(&command) + .spawn() + .unwrap(); + } + } +} + +/// Heuristic mapping from a container image to its native package manager. +/// Returns just the manager binary (`apt`, `dnf`, `pacman`, ...); the user +/// supplies the subcommand and packages separately. +fn pick_pkg_manager_for_uninstall(image: &str) -> &'static str { + let lower = image.to_lowercase(); + // Arch family + if lower.contains("arch") + || lower.contains("blackarch") + || lower.contains("bazzite-arch") + || lower.contains("arch-toolbox") + { + return "pacman"; + } + // Debian / Ubuntu family + if lower.contains("ubuntu") + || lower.contains("toolbx/ubuntu") + || lower.contains("ubuntu-toolbox") + || lower.contains("debian") + || lower.contains("neurodebian") + || lower.contains("mint") + || lower.contains("kali") + || lower.contains("neon") + { + return "apt"; + } + // Fedora family (also RHEL clones) + if lower.contains("fedora") + || lower.contains("bluefin") + || lower.contains("fedoraproject.org/fedora") + || lower.contains("centos") + || lower.contains("rhel") + || lower.contains("rocky") + || lower.contains("alma") + || lower.contains("ubi") + || lower.contains("amazonlinux") + || lower.contains("oracle") + { + return "dnf"; + } + // openSUSE + if lower.contains("opensuse") || lower.contains("tumbleweed") || lower.contains("leap") { + return "zypper"; + } + if lower.contains("alpine") || lower.contains("wolfi") || lower.contains("chainguard") { + return "apk"; + } + if lower.contains("void") { + return "xbps-install"; + } + if lower.contains("gentoo") { + return "emerge"; + } + if lower.contains("slack") { + return "installpkg"; + } + // Default: most container images in distrobox's supported list ship + // apt or dnf; apt is the safer guess because it errors loudly on + // non-apt distros instead of partial-success. + "apt" +} + pub fn upgrade_all_boxes() { let (term, sep, term_is_flatpak) = get_terminal_and_separator_arg(); let command = format!("distrobox-upgrade --all"); diff --git a/src/main.rs b/src/main.rs index a20908a..1b29602 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,7 +21,7 @@ use distrobox_handler::{ get_apps_in_box, get_available_images_with_distro_name, get_binaries_exported_from_box, get_number_of_boxes, install_deb_in_box, install_rpm_in_box, open_terminal_in_box, remove_app_from_host, remove_exported_binary_from_box, run_command_in_box, stop_box, - upgrade_all_boxes, upgrade_box, DBox, DBoxApp, + uninstall_app_in_box, upgrade_all_boxes, upgrade_box, DBox, DBoxApp, }; mod utils; @@ -510,9 +510,10 @@ fn make_box_tab(dbox: &DBox, window: &ApplicationWindow, tab_num: u32) -> gtk::B show_applications_row.set_activatable(true); let show_bn_clone = box_name.clone(); + let show_img_clone = dbox.image_url.clone(); let win_clone = window.clone(); show_applications_row.connect_activated(move |_row| { - on_show_applications_clicked(&win_clone, show_bn_clone.clone()); + on_show_applications_clicked(&win_clone, show_bn_clone.clone(), show_img_clone.clone()); }); // Install Deb Icon @@ -1216,7 +1217,7 @@ fn build_empty_state_page(title: &str) -> adw::StatusPage { status_page } -fn on_show_applications_clicked(window: &ApplicationWindow, box_name: String) { +fn on_show_applications_clicked(window: &ApplicationWindow, box_name: String, box_image: String) { let apps_popup = gtk::Window::builder() // TRANSLATORS: Window Title - shows list of installed applications in distrobox .title(gettext("Installed Applications")) @@ -1357,6 +1358,28 @@ fn on_show_applications_clicked(window: &ApplicationWindow, box_name: String) { row.add_suffix(&run_btn); row.add_suffix(>k::Separator::new(gtk::Orientation::Horizontal)); + // Uninstall button: removes the application + // from inside the box via the distro's + // package manager. We pass the executable + // name (which doubles as the package name + // for most distros) plus the box's image so + // the right manager can be picked. + // TRANSLATORS: Button Label + let uninstall_btn = gtk::Button::with_label(&gettext("Uninstall")); + uninstall_btn.add_css_class("pill"); + uninstall_btn.set_width_request(120); + let un_box_name = box_name.clone(); + let un_image = box_image.clone(); + let un_exec = app.exec_name.clone(); + uninstall_btn.connect_clicked(move |_btn| { + uninstall_app_in_box( + un_box_name.clone(), + un_image.clone(), + format!("remove {un_exec}"), + ); + }); + row.add_suffix(&uninstall_btn); + if app.is_on_host { let remove_from_menu_btn = //TRANSLATORS: Button Label From 54a12ff57c40b65e47d93531126e9f9f884f696f Mon Sep 17 00:00:00 2001 From: Kacper Paczos Date: Wed, 19 Aug 2026 19:30:06 +0200 Subject: [PATCH 2/4] fix: ask the box which package owns the binary, and quote what we run Uninstall passed the desktop file's Exec= value straight to the package manager as if it were a package name. The two rarely agree - gimp lives in gimp-2.10 - so the happy path mostly failed. Worse, the value was interpolated unquoted into a bash -c line, and a desktop file comes from the container image, not from the user: an Exec= with a semicolon in it would have run whatever it liked with the host's home mounted. The manager now gets asked who owns the binary (dpkg -S, rpm -qf or pacman -Qqo, after resolving the name through command -v with /usr/games widening the search, since desktop files may point outside the login PATH), with the bare executable name kept as the fallback guess. Everything that reaches the terminal command is shell-quoted, and the untrusted values travel to the resolution queries as positional parameters rather than shell text. Removal is also spelled per manager now - pacman takes -R, apk takes del, and slackware removes with removepkg despite installing with installpkg. The pure pieces - token splitting, quoting, ownership-output parsing and the removal table - come with unit tests. --- src/distrobox_handler.rs | 210 ++++++++++++++++++++++++++++++++++++--- src/main.rs | 9 +- 2 files changed, 201 insertions(+), 18 deletions(-) diff --git a/src/distrobox_handler.rs b/src/distrobox_handler.rs index aaa63d9..bef69ea 100644 --- a/src/distrobox_handler.rs +++ b/src/distrobox_handler.rs @@ -810,24 +810,35 @@ pub fn clone_box(box_to_clone: &str, new_name: &str) -> String { ) } -/// Uninstalls an application from inside a box by running the distrobox's +/// Uninstalls an application from inside a box by running the distro's /// package manager via `sudo` in a terminal. /// -/// The actual command depends on the image. We pick a manager by matching the -/// image URL against the same set of regexes `install_deb_in_box` / -/// `install_rpm_in_box` use, falling back to `apt` for any unknown deb-style -/// distro. The `pkg_command` argument is the user-chosen subcommand and -/// targets list, e.g. `remove vim git` or `purge neofetch`. +/// `app_exec` is the raw `Exec=` value of the application's desktop file. +/// The binary usually is not named after its package (gimp lives in +/// gimp-2.10, for instance), so instead of guessing, the box's own package +/// manager is asked which package owns the binary; only if that fails does +/// the bare executable name serve as the guess. Everything interpolated +/// into the terminal command is shell-quoted, because the desktop file - +/// and therefore `app_exec` - comes from the container image, not from the +/// user. /// -/// Spawning a terminal (rather than running the command in-process) lets the -/// user see the `sudo` prompt and answer it interactively. We do not remove -/// the `.desktop` export on the host - that is a separate, reversible action -/// the user can take from the same row. -pub fn uninstall_app_in_box(box_name: String, image: String, pkg_command: String) { +/// Spawning a terminal (rather than running the command in-process) lets +/// the user see what will be removed and answer the manager's prompt. We do +/// not remove the `.desktop` export on the host - that is a separate, +/// reversible action the user can take from the same row. +pub fn uninstall_app_in_box(box_name: String, image: String, app_exec: String) { let (term, sep, term_is_flatpak) = get_terminal_and_separator_arg(); let manager = pick_pkg_manager_for_uninstall(&image); - let inner = format!("sudo {manager} {pkg_command}"); - let command = format!("distrobox enter {box_name} -- /usr/bin/env bash -c \"{inner}\""); + let (remove_bin, remove_arg) = manager_remove_invocation(manager); + + let package = resolve_package_for_binary(&box_name, manager, &app_exec) + .unwrap_or_else(|| first_token(&app_exec).to_string()); + + let command = format!( + "distrobox enter {} -- sudo {remove_bin} {remove_arg} {}", + shell_quote(&box_name), + shell_quote(&package), + ); if is_flatpak() { if term_is_flatpak { @@ -876,6 +887,116 @@ pub fn uninstall_app_in_box(box_name: String, image: String, pkg_command: String } } +/// The first whitespace-separated token of an `Exec=` line - the executable +/// itself, with any arguments dropped. +fn first_token(exec_line: &str) -> &str { + exec_line.split_whitespace().next().unwrap_or(exec_line) +} + +/// Wraps a string in single quotes for embedding into a `bash -c` command +/// line, escaping any single quotes inside it. +fn shell_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', "'\\''")) +} + +/// How a given manager spells "remove this package". Returns the binary to +/// call and its removal argument - not always the manager itself: slackware +/// installs with installpkg but removes with removepkg. +fn manager_remove_invocation(manager: &str) -> (&'static str, &'static str) { + match manager { + "pacman" => ("pacman", "-R"), + "apk" => ("apk", "del"), + "xbps-install" => ("xbps-remove", ""), + "emerge" => ("emerge", "--unmerge"), + "installpkg" => ("removepkg", ""), + "dnf" => ("dnf", "remove"), + "zypper" => ("zypper", "remove"), + _ => ("apt", "remove"), + } +} + +/// The package name owning `exec_line`'s binary, according to the box's own +/// package manager. Asks `command -v` inside the box for the full path first, +/// then the manager who owns that path. Returns None for managers we do not +/// know how to ask, or when either step comes back empty - the caller then +/// falls back to the bare executable name. +/// +/// Both queries pass the untrusted values as positional parameters rather +/// than splicing them into shell text. +fn resolve_package_for_binary(box_name: &str, manager: &str, exec_line: &str) -> Option { + let binary = first_token(exec_line); + + let path_out = get_command_output( + "distrobox", + Some(&[ + "enter", + box_name, + "--", + "bash", + "-c", + "command -v -- \"$1\"", + "_", + binary, + ]), + ); + let path = path_out + .lines() + .find(|l| l.starts_with('/'))? + .trim() + .to_string(); + + let owner_out = match manager { + "apt" => get_command_output( + "distrobox", + Some(&["enter", box_name, "--", "dpkg", "-S", &path]), + ), + "dnf" | "zypper" => get_command_output( + "distrobox", + Some(&[ + "enter", + box_name, + "--", + "rpm", + "-qf", + "--queryformat", + "%{NAME}", + &path, + ]), + ), + "pacman" => get_command_output( + "distrobox", + Some(&["enter", box_name, "--", "pacman", "-Qqo", &path]), + ), + _ => return None, + }; + + parse_package_owner(manager, &owner_out) +} + +/// Pulls the package name out of an ownership query's output. +/// dpkg says `cowsay: /usr/games/cowsay` (or `libc6:amd64: /lib/...`), +/// rpm prints the bare name thanks to --queryformat, pacman -Qqo prints the +/// bare name on its own line. +fn parse_package_owner(manager: &str, output: &str) -> Option { + let line = output.lines().map(str::trim).find(|l| !l.is_empty())?; + + if line.contains("no path found") || line.contains("not owned") || line.contains("error") { + return None; + } + + let name = match manager { + "apt" => line.split(':').next()?, + _ => line, + }; + + let name = name.trim(); + if name.is_empty() { + return None; + } + + Some(name.to_string()) +} + /// Heuristic mapping from a container image to its native package manager. /// Returns just the manager binary (`apt`, `dnf`, `pacman`, ...); the user /// supplies the subcommand and packages separately. @@ -1064,3 +1185,66 @@ mod stream_tests { assert!(exists, "streaming create did not produce a listable box"); } } + +#[cfg(test)] +mod tests { + use super::{first_token, manager_remove_invocation, parse_package_owner, shell_quote}; + + #[test] + fn first_token_drops_arguments() { + assert_eq!(first_token("gimp-2.10 --new-instance"), "gimp-2.10"); + assert_eq!(first_token("cowsay"), "cowsay"); + assert_eq!(first_token(" spaced out "), "spaced"); + } + + /// The Exec= line comes from the container image, so a hostile one must + /// come out as inert quoted text, not as extra shell syntax. + #[test] + fn shell_quote_neutralises_hostile_input() { + assert_eq!(shell_quote("cowsay"), "'cowsay'"); + assert_eq!(shell_quote("a;rm -rf $HOME"), "'a;rm -rf $HOME'"); + assert_eq!(shell_quote("a'b"), r#"'a'\''b'"#); + } + + #[test] + fn parses_dpkg_ownership() { + assert_eq!( + parse_package_owner("apt", "cowsay: /usr/games/cowsay\n"), + Some("cowsay".to_string()) + ); + // multi-arch packages carry the architecture after a second colon + assert_eq!( + parse_package_owner("apt", "libc6:amd64: /lib/x86_64-linux-gnu/libc.so.6\n"), + Some("libc6".to_string()) + ); + assert_eq!( + parse_package_owner("apt", "dpkg-query: no path found matching pattern /x\n"), + None + ); + } + + #[test] + fn parses_rpm_and_pacman_ownership() { + assert_eq!( + parse_package_owner("dnf", "cowsay"), + Some("cowsay".to_string()) + ); + assert_eq!( + parse_package_owner("pacman", "cowsay\n"), + Some("cowsay".to_string()) + ); + assert_eq!( + parse_package_owner("pacman", "error: No package owns /usr/bin/x\n"), + None + ); + assert_eq!(parse_package_owner("dnf", "\n"), None); + } + + #[test] + fn removal_is_spelled_per_manager() { + assert_eq!(manager_remove_invocation("apt"), ("apt", "remove")); + assert_eq!(manager_remove_invocation("pacman"), ("pacman", "-R")); + // slackware installs with installpkg but removes with removepkg + assert_eq!(manager_remove_invocation("installpkg"), ("removepkg", "")); + } +} diff --git a/src/main.rs b/src/main.rs index 1b29602..1a0e187 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1360,10 +1360,9 @@ fn on_show_applications_clicked(window: &ApplicationWindow, box_name: String, bo // Uninstall button: removes the application // from inside the box via the distro's - // package manager. We pass the executable - // name (which doubles as the package name - // for most distros) plus the box's image so - // the right manager can be picked. + // package manager. The handler asks the + // box which package owns the executable, + // so the raw Exec= value is enough here. // TRANSLATORS: Button Label let uninstall_btn = gtk::Button::with_label(&gettext("Uninstall")); uninstall_btn.add_css_class("pill"); @@ -1375,7 +1374,7 @@ fn on_show_applications_clicked(window: &ApplicationWindow, box_name: String, bo uninstall_app_in_box( un_box_name.clone(), un_image.clone(), - format!("remove {un_exec}"), + un_exec.clone(), ); }); row.add_suffix(&uninstall_btn); From f80a3c3dc47ee0b2250bba8119f4ebb8774308c7 Mon Sep 17 00:00:00 2001 From: Kacper Paczos Date: Sun, 23 Aug 2026 09:50:18 +0200 Subject: [PATCH 3/4] simplify: reuse the manager detection and the install terminal helper master already knows how to tell a box's package manager from its image (utils::detect_pkg_manager), so the uninstall path now asks that instead of carrying its own copy of the table. The terminal spawn is shared with the .deb/.rpm install path too: run_install_in_terminal takes the manager's arguments as a slice, so install and remove go through the same argv-based call - which also means there is no shell line to quote any more, and the quoting helper and its test go away with it. --- src/distrobox_handler.rs | 270 ++++++++++++--------------------------- 1 file changed, 81 insertions(+), 189 deletions(-) diff --git a/src/distrobox_handler.rs b/src/distrobox_handler.rs index bef69ea..7615ca2 100644 --- a/src/distrobox_handler.rs +++ b/src/distrobox_handler.rs @@ -1,6 +1,6 @@ use crate::utils::{ - get_command_output, get_host_desktop_files, get_repository_list, - get_terminal_and_separator_arg, is_flatpak, is_nvidia, run_command, + detect_pkg_manager, get_command_output, get_host_desktop_files, get_repository_list, + get_terminal_and_separator_arg, is_flatpak, is_nvidia, run_command, PkgManager, }; use std::io::{BufRead, BufReader}; use std::process::{Command, Stdio}; @@ -697,11 +697,12 @@ pub fn get_number_of_boxes() -> u32 { u32::try_from(get_all_distroboxes().len()).unwrap_or(u32::MAX) } -/// Runs the `distrobox enter NAME -- sudo install PATH` command -/// in a terminal so the user can confirm the `sudo` prompt. Used by both -/// the `.deb` and `.rpm` install paths - the only thing that varies is -/// which package manager we ask for. -fn run_install_in_terminal(box_name: &str, file_path: &str, manager: &str) { +/// Runs `distrobox enter NAME -- sudo ` in a terminal so +/// the user can answer the `sudo` prompt and the manager's own confirmation. +/// Used by the `.deb`/`.rpm` install paths and by uninstall - only the +/// manager and its arguments differ. The pieces are passed as separate +/// arguments, never through a shell, so nothing in them is interpreted. +fn run_pkg_command_in_terminal(box_name: &str, manager: &str, args: &[&str]) { let (term, sep, term_is_flatpak) = get_terminal_and_separator_arg(); if is_flatpak() { @@ -718,8 +719,7 @@ fn run_install_in_terminal(box_name: &str, file_path: &str, manager: &str) { .arg("--") .arg("sudo") .arg(manager) - .arg("install") - .arg(file_path) + .args(args) .spawn() .unwrap(); } else { @@ -733,8 +733,7 @@ fn run_install_in_terminal(box_name: &str, file_path: &str, manager: &str) { .arg("--") .arg("sudo") .arg(manager) - .arg("install") - .arg(file_path) + .args(args) .spawn() .unwrap(); } @@ -749,8 +748,7 @@ fn run_install_in_terminal(box_name: &str, file_path: &str, manager: &str) { .arg("--") .arg("sudo") .arg(manager) - .arg("install") - .arg(file_path) + .args(args) .spawn() .unwrap(); } else { @@ -762,8 +760,7 @@ fn run_install_in_terminal(box_name: &str, file_path: &str, manager: &str) { .arg("--") .arg("sudo") .arg(manager) - .arg("install") - .arg(file_path) + .args(args) .spawn() .unwrap(); } @@ -775,8 +772,8 @@ fn run_install_in_terminal(box_name: &str, file_path: &str, manager: &str) { /// it; the user gets a visible error in the terminal if that guess is /// wrong. pub fn install_deb_in_box(box_name: String, image: String, file_path: String) { - let manager = match crate::utils::detect_pkg_manager(&image) { - Some(crate::utils::PkgManager::Apt) => "apt", + let manager = match detect_pkg_manager(&image) { + Some(PkgManager::Apt) => "apt", // .deb is not the native package format for non-apt distros; // refusing here would be safer than producing an apt-only error, // but the old behaviour was to always try apt, so we keep that @@ -784,7 +781,7 @@ pub fn install_deb_in_box(box_name: String, image: String, file_path: String) { // where the user can read it. _ => "apt", }; - run_install_in_terminal(&box_name, &file_path, manager); + run_pkg_command_in_terminal(&box_name, manager, &["install", &file_path]); } /// Tries to install a .rpm file in the box using the package manager we @@ -793,12 +790,12 @@ pub fn install_deb_in_box(box_name: String, image: String, file_path: String) { /// Like the `.deb` path, the actual command runs in a terminal so the /// user can confirm the `sudo` prompt. pub fn install_rpm_in_box(box_name: String, image: String, file_path: String) { - let manager = match crate::utils::detect_pkg_manager(&image) { - Some(crate::utils::PkgManager::Zypper) => "zypper", - Some(crate::utils::PkgManager::Dnf) => "dnf", + let manager = match detect_pkg_manager(&image) { + Some(PkgManager::Zypper) => "zypper", + Some(PkgManager::Dnf) => "dnf", _ => "dnf", }; - run_install_in_terminal(&box_name, &file_path, manager); + run_pkg_command_in_terminal(&box_name, manager, &["install", &file_path]); } pub fn clone_box(box_to_clone: &str, new_name: &str) -> String { @@ -811,80 +808,31 @@ pub fn clone_box(box_to_clone: &str, new_name: &str) -> String { } /// Uninstalls an application from inside a box by running the distro's -/// package manager via `sudo` in a terminal. +/// package manager via `sudo` in a terminal, so the user sees what will be +/// removed and can answer the manager's prompt. /// /// `app_exec` is the raw `Exec=` value of the application's desktop file. /// The binary usually is not named after its package (gimp lives in /// gimp-2.10, for instance), so instead of guessing, the box's own package /// manager is asked which package owns the binary; only if that fails does -/// the bare executable name serve as the guess. Everything interpolated -/// into the terminal command is shell-quoted, because the desktop file - -/// and therefore `app_exec` - comes from the container image, not from the -/// user. -/// -/// Spawning a terminal (rather than running the command in-process) lets -/// the user see what will be removed and answer the manager's prompt. We do -/// not remove the `.desktop` export on the host - that is a separate, -/// reversible action the user can take from the same row. +/// the bare executable name serve as the guess. The host-side `.desktop` +/// export is left alone - removing it is a separate, reversible action the +/// user can take from the same row. pub fn uninstall_app_in_box(box_name: String, image: String, app_exec: String) { - let (term, sep, term_is_flatpak) = get_terminal_and_separator_arg(); - let manager = pick_pkg_manager_for_uninstall(&image); + // Unknown images fall back to apt, as the .deb install path does: it + // fails loudly in the terminal rather than half-working. + let manager = detect_pkg_manager(&image).unwrap_or(PkgManager::Apt); let (remove_bin, remove_arg) = manager_remove_invocation(manager); let package = resolve_package_for_binary(&box_name, manager, &app_exec) .unwrap_or_else(|| first_token(&app_exec).to_string()); - let command = format!( - "distrobox enter {} -- sudo {remove_bin} {remove_arg} {}", - shell_quote(&box_name), - shell_quote(&package), - ); - - if is_flatpak() { - if term_is_flatpak { - Command::new("flatpak-spawn") - .arg("--host") - .arg("flatpak") - .arg("run") - .arg(term) - .arg(sep) - .arg("bash") - .arg("-c") - .arg(&command) - .spawn() - .unwrap(); - } else { - Command::new("flatpak-spawn") - .arg("--host") - .arg(term) - .arg(sep) - .arg("bash") - .arg("-c") - .arg(&command) - .spawn() - .unwrap(); - } - } else { - if term_is_flatpak { - Command::new("flatpak") - .arg("run") - .arg(term) - .arg(sep) - .arg("bash") - .arg("-c") - .arg(&command) - .spawn() - .unwrap(); - } else { - Command::new(term) - .arg(sep) - .arg("bash") - .arg("-c") - .arg(&command) - .spawn() - .unwrap(); - } + let mut args: Vec<&str> = Vec::new(); + if let Some(arg) = remove_arg { + args.push(arg); } + args.push(&package); + run_pkg_command_in_terminal(&box_name, remove_bin, &args); } /// The first whitespace-separated token of an `Exec=` line - the executable @@ -893,25 +841,19 @@ fn first_token(exec_line: &str) -> &str { exec_line.split_whitespace().next().unwrap_or(exec_line) } -/// Wraps a string in single quotes for embedding into a `bash -c` command -/// line, escaping any single quotes inside it. -fn shell_quote(s: &str) -> String { - format!("'{}'", s.replace('\'', "'\\''")) -} - -/// How a given manager spells "remove this package". Returns the binary to -/// call and its removal argument - not always the manager itself: slackware -/// installs with installpkg but removes with removepkg. -fn manager_remove_invocation(manager: &str) -> (&'static str, &'static str) { +/// How a given manager spells "remove this package": the binary to call and +/// its removal argument, if it takes one. Not always the manager itself - +/// slackware installs with installpkg but removes with removepkg. +fn manager_remove_invocation(manager: PkgManager) -> (&'static str, Option<&'static str>) { match manager { - "pacman" => ("pacman", "-R"), - "apk" => ("apk", "del"), - "xbps-install" => ("xbps-remove", ""), - "emerge" => ("emerge", "--unmerge"), - "installpkg" => ("removepkg", ""), - "dnf" => ("dnf", "remove"), - "zypper" => ("zypper", "remove"), - _ => ("apt", "remove"), + PkgManager::Apt => ("apt", Some("remove")), + PkgManager::Dnf => ("dnf", Some("remove")), + PkgManager::Zypper => ("zypper", Some("remove")), + PkgManager::Pacman => ("pacman", Some("-R")), + PkgManager::Apk => ("apk", Some("del")), + PkgManager::Xbps => ("xbps-remove", None), + PkgManager::Emerge => ("emerge", Some("--unmerge")), + PkgManager::Installpkg => ("removepkg", None), } } @@ -923,7 +865,11 @@ fn manager_remove_invocation(manager: &str) -> (&'static str, &'static str) { /// /// Both queries pass the untrusted values as positional parameters rather /// than splicing them into shell text. -fn resolve_package_for_binary(box_name: &str, manager: &str, exec_line: &str) -> Option { +fn resolve_package_for_binary( + box_name: &str, + manager: PkgManager, + exec_line: &str, +) -> Option { let binary = first_token(exec_line); let path_out = get_command_output( @@ -946,11 +892,11 @@ fn resolve_package_for_binary(box_name: &str, manager: &str, exec_line: &str) -> .to_string(); let owner_out = match manager { - "apt" => get_command_output( + PkgManager::Apt => get_command_output( "distrobox", Some(&["enter", box_name, "--", "dpkg", "-S", &path]), ), - "dnf" | "zypper" => get_command_output( + PkgManager::Dnf | PkgManager::Zypper => get_command_output( "distrobox", Some(&[ "enter", @@ -963,7 +909,7 @@ fn resolve_package_for_binary(box_name: &str, manager: &str, exec_line: &str) -> &path, ]), ), - "pacman" => get_command_output( + PkgManager::Pacman => get_command_output( "distrobox", Some(&["enter", box_name, "--", "pacman", "-Qqo", &path]), ), @@ -977,7 +923,7 @@ fn resolve_package_for_binary(box_name: &str, manager: &str, exec_line: &str) -> /// dpkg says `cowsay: /usr/games/cowsay` (or `libc6:amd64: /lib/...`), /// rpm prints the bare name thanks to --queryformat, pacman -Qqo prints the /// bare name on its own line. -fn parse_package_owner(manager: &str, output: &str) -> Option { +fn parse_package_owner(manager: PkgManager, output: &str) -> Option { let line = output.lines().map(str::trim).find(|l| !l.is_empty())?; if line.contains("no path found") || line.contains("not owned") || line.contains("error") { @@ -985,7 +931,7 @@ fn parse_package_owner(manager: &str, output: &str) -> Option { } let name = match manager { - "apt" => line.split(':').next()?, + PkgManager::Apt => line.split(':').next()?, _ => line, }; @@ -997,67 +943,6 @@ fn parse_package_owner(manager: &str, output: &str) -> Option { Some(name.to_string()) } -/// Heuristic mapping from a container image to its native package manager. -/// Returns just the manager binary (`apt`, `dnf`, `pacman`, ...); the user -/// supplies the subcommand and packages separately. -fn pick_pkg_manager_for_uninstall(image: &str) -> &'static str { - let lower = image.to_lowercase(); - // Arch family - if lower.contains("arch") - || lower.contains("blackarch") - || lower.contains("bazzite-arch") - || lower.contains("arch-toolbox") - { - return "pacman"; - } - // Debian / Ubuntu family - if lower.contains("ubuntu") - || lower.contains("toolbx/ubuntu") - || lower.contains("ubuntu-toolbox") - || lower.contains("debian") - || lower.contains("neurodebian") - || lower.contains("mint") - || lower.contains("kali") - || lower.contains("neon") - { - return "apt"; - } - // Fedora family (also RHEL clones) - if lower.contains("fedora") - || lower.contains("bluefin") - || lower.contains("fedoraproject.org/fedora") - || lower.contains("centos") - || lower.contains("rhel") - || lower.contains("rocky") - || lower.contains("alma") - || lower.contains("ubi") - || lower.contains("amazonlinux") - || lower.contains("oracle") - { - return "dnf"; - } - // openSUSE - if lower.contains("opensuse") || lower.contains("tumbleweed") || lower.contains("leap") { - return "zypper"; - } - if lower.contains("alpine") || lower.contains("wolfi") || lower.contains("chainguard") { - return "apk"; - } - if lower.contains("void") { - return "xbps-install"; - } - if lower.contains("gentoo") { - return "emerge"; - } - if lower.contains("slack") { - return "installpkg"; - } - // Default: most container images in distrobox's supported list ship - // apt or dnf; apt is the safer guess because it errors loudly on - // non-apt distros instead of partial-success. - "apt" -} - pub fn upgrade_all_boxes() { let (term, sep, term_is_flatpak) = get_terminal_and_separator_arg(); let command = format!("distrobox-upgrade --all"); @@ -1188,7 +1073,8 @@ mod stream_tests { #[cfg(test)] mod tests { - use super::{first_token, manager_remove_invocation, parse_package_owner, shell_quote}; + use super::{first_token, manager_remove_invocation, parse_package_owner}; + use crate::utils::PkgManager; #[test] fn first_token_drops_arguments() { @@ -1197,28 +1083,25 @@ mod tests { assert_eq!(first_token(" spaced out "), "spaced"); } - /// The Exec= line comes from the container image, so a hostile one must - /// come out as inert quoted text, not as extra shell syntax. - #[test] - fn shell_quote_neutralises_hostile_input() { - assert_eq!(shell_quote("cowsay"), "'cowsay'"); - assert_eq!(shell_quote("a;rm -rf $HOME"), "'a;rm -rf $HOME'"); - assert_eq!(shell_quote("a'b"), r#"'a'\''b'"#); - } - #[test] fn parses_dpkg_ownership() { assert_eq!( - parse_package_owner("apt", "cowsay: /usr/games/cowsay\n"), + parse_package_owner(PkgManager::Apt, "cowsay: /usr/games/cowsay\n"), Some("cowsay".to_string()) ); // multi-arch packages carry the architecture after a second colon assert_eq!( - parse_package_owner("apt", "libc6:amd64: /lib/x86_64-linux-gnu/libc.so.6\n"), + parse_package_owner( + PkgManager::Apt, + "libc6:amd64: /lib/x86_64-linux-gnu/libc.so.6\n" + ), Some("libc6".to_string()) ); assert_eq!( - parse_package_owner("apt", "dpkg-query: no path found matching pattern /x\n"), + parse_package_owner( + PkgManager::Apt, + "dpkg-query: no path found matching pattern /x\n" + ), None ); } @@ -1226,25 +1109,34 @@ mod tests { #[test] fn parses_rpm_and_pacman_ownership() { assert_eq!( - parse_package_owner("dnf", "cowsay"), + parse_package_owner(PkgManager::Dnf, "cowsay"), Some("cowsay".to_string()) ); assert_eq!( - parse_package_owner("pacman", "cowsay\n"), + parse_package_owner(PkgManager::Pacman, "cowsay\n"), Some("cowsay".to_string()) ); assert_eq!( - parse_package_owner("pacman", "error: No package owns /usr/bin/x\n"), + parse_package_owner(PkgManager::Pacman, "error: No package owns /usr/bin/x\n"), None ); - assert_eq!(parse_package_owner("dnf", "\n"), None); + assert_eq!(parse_package_owner(PkgManager::Dnf, "\n"), None); } #[test] fn removal_is_spelled_per_manager() { - assert_eq!(manager_remove_invocation("apt"), ("apt", "remove")); - assert_eq!(manager_remove_invocation("pacman"), ("pacman", "-R")); + assert_eq!( + manager_remove_invocation(PkgManager::Apt), + ("apt", Some("remove")) + ); + assert_eq!( + manager_remove_invocation(PkgManager::Pacman), + ("pacman", Some("-R")) + ); // slackware installs with installpkg but removes with removepkg - assert_eq!(manager_remove_invocation("installpkg"), ("removepkg", "")); + assert_eq!( + manager_remove_invocation(PkgManager::Installpkg), + ("removepkg", None) + ); } } From d34c0d91a8963d49a22d7e87f7c26e15f2d1da12 Mon Sep 17 00:00:00 2001 From: Kacper Paczos Date: Sun, 23 Aug 2026 10:08:31 +0200 Subject: [PATCH 4/4] fix: look in the games directories when resolving the binary A non-login shell inside the box does not have /usr/games on its PATH, so command -v could not find a desktop file's binary there and the resolver fell back to the bare name. The lookup now adds the games directories for that one query. --- src/distrobox_handler.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/distrobox_handler.rs b/src/distrobox_handler.rs index 7615ca2..49006fc 100644 --- a/src/distrobox_handler.rs +++ b/src/distrobox_handler.rs @@ -863,8 +863,10 @@ fn manager_remove_invocation(manager: PkgManager) -> (&'static str, Option<&'sta /// know how to ask, or when either step comes back empty - the caller then /// falls back to the bare executable name. /// -/// Both queries pass the untrusted values as positional parameters rather -/// than splicing them into shell text. +/// The lookup also searches the games directories: desktop files can point +/// there (Debian's cowsay lives in /usr/games) while a non-login shell's PATH +/// does not include them. Both queries pass the untrusted values as positional +/// parameters rather than splicing them into shell text. fn resolve_package_for_binary( box_name: &str, manager: PkgManager, @@ -880,7 +882,7 @@ fn resolve_package_for_binary( "--", "bash", "-c", - "command -v -- \"$1\"", + "PATH=\"$PATH:/usr/games:/usr/local/games\" command -v -- \"$1\"", "_", binary, ]),