diff --git a/.cargo/audit.toml b/.cargo/audit.toml new file mode 100644 index 0000000..5787fef --- /dev/null +++ b/.cargo/audit.toml @@ -0,0 +1,11 @@ +# Remaining advisories are transitive through eframe 0.34 / winit / wayland-scanner. +# They are documented in the security cleanup PR. Do not ignore new findings. +[advisories] +ignore = [ + # quick-xml 0.39.2 via wayland-scanner 0.31.10 (build-time XML protocol parser). + # wayland-scanner pins ^0.39; upgrading eframe to pick up a newer scanner also + # raises MSRV past 1.92 and changes the UI stack. The crate is not used on + # untrusted user input in this app. + "RUSTSEC-2026-0194", + "RUSTSEC-2026-0195", +] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6f60bc7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,53 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + rust: + name: fmt, clippy, test, audit + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install GUI build dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libxcb-render0-dev \ + libxcb-shape0-dev \ + libxcb-xfixes0-dev \ + libxkbcommon-dev \ + libssl-dev \ + pkg-config + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: rustfmt + run: cargo fmt --check + + - name: clippy + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: test + run: cargo test --all-targets + + - name: Install cargo-audit + uses: taiki-e/install-action@v2 + with: + tool: cargo-audit + + - name: cargo audit + run: cargo audit diff --git a/Cargo.lock b/Cargo.lock index d88b671..7219a56 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -334,16 +334,6 @@ dependencies = [ "libc", ] -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -357,7 +347,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", - "core-foundation 0.9.4", + "core-foundation", "core-graphics-types", "foreign-types", "libc", @@ -370,7 +360,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" dependencies = [ "bitflags 1.3.2", - "core-foundation 0.9.4", + "core-foundation", "libc", ] @@ -656,7 +646,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1306,9 +1296,9 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memmap2" -version = "0.9.10" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" dependencies = [ "libc", ] @@ -2067,7 +2057,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2817,15 +2807,15 @@ dependencies = [ [[package]] name = "webbrowser" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72" +checksum = "ef62a3d5f7b2411119a11b6f62570dbff91d7105e011a20fb83fbf8f5761c40f" dependencies = [ - "core-foundation 0.10.1", "jni", "log", "ndk-context", "objc2 0.6.4", + "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "url", "web-sys", @@ -2956,7 +2946,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3145,7 +3135,7 @@ dependencies = [ "calloop 0.13.0", "cfg_aliases", "concurrent-queue", - "core-foundation 0.9.4", + "core-foundation", "core-graphics", "cursor-icon", "dpi", diff --git a/Cargo.toml b/Cargo.toml index e240a56..66bef24 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ name = "linux-it-guy-toolbox" version = "0.1.0" edition = "2024" +rust-version = "1.92" description = "A modern Rust desktop toolbox for Linux app installs and admin tasks." license = "MIT" diff --git a/README.md b/README.md index aa7593e..68d2a51 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,19 @@ A fast Rust desktop app for installing/removing apps and running Linux admin tas ## 🚀 Quick Start ```bash -git clone https://github.com/TheLinuxITGuy/Toolbox.git && cd Toolbox && chmod +x linux-it-guy-toolbox && ./linux-it-guy-toolbox +git clone https://github.com/TheLinuxITGuy/Toolbox.git +cd Toolbox +cargo build --release +./target/release/linux-it-guy-toolbox +``` + +Prebuilt binaries are published on the [GitHub Releases](https://github.com/TheLinuxITGuy/Toolbox/releases) page. + +If your distribution's `rustc`/`cargo` is older than 1.92: + +```bash +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +. "$HOME/.cargo/env" ``` ## 📖 Usage @@ -36,7 +48,7 @@ git clone https://github.com/TheLinuxITGuy/Toolbox.git && cd Toolbox && chmod +x ## 🔧 Requirements -**If the Quick Start script runs into any issues, your distribution might be missing a few foundational packages.** +**If `cargo build --release` fails, your distribution might be missing git or GUI build packages (xcb, xkbcommon, OpenSSL, and similar).** ### Arch ```bash diff --git a/VMware Player Fix/vmware-fix.sh b/VMware Player Fix/vmware-fix.sh index aa526e0..b1a1770 100644 --- a/VMware Player Fix/vmware-fix.sh +++ b/VMware Player Fix/vmware-fix.sh @@ -1,46 +1,49 @@ -#!/bin/bash - -echo -e "\033[0;32m=====================================" -echo -e "\033[1;32mThe Linux IT Guy - Linux Mint Scripts" -echo -e "\033[1;32mVMware Player Fix" -echo -e "\033[0;32m=====================================\033[0m" - -# Change permissions to make the bundle executable -chmod u+x ~/Downloads/VMware-*.bundle - -# Check if VMware Player bundle exists in ~/Downloads -if [ ! -f ~/Downloads/VMware-*.bundle ]; then - echo "Run the script from ~/Downloads/" +#!/usr/bin/env bash +set -euo pipefail + +printf '=====================================\n' +printf 'The Linux IT Guy - Linux Mint Scripts\n' +printf 'VMware Player Fix\n' +printf '=====================================\n' + +DOWNLOADS="${HOME}/Downloads" +MODULE_VERSION="workstation-17.5.0" +MODULE_URL="https://github.com/mkubecek/vmware-host-modules/archive/${MODULE_VERSION}.tar.gz" +MODULE_ARCHIVE="${DOWNLOADS}/${MODULE_VERSION}.tar.gz" +MODULE_DIR="${DOWNLOADS}/vmware-host-modules-${MODULE_VERSION}" + +if [[ ! -d "$DOWNLOADS" ]]; then + echo "Expected ${DOWNLOADS} to exist and contain the VMware Player bundle." >&2 exit 1 fi -# Run the VMware Player installer -sudo ~/Downloads/VMware-*.bundle - -# Change directory to ~/Downloads -cd ~/Downloads - -# Download the VMware host modules -wget https://github.com/mkubecek/vmware-host-modules/archive/workstation-17.5.0.tar.gz +shopt -s nullglob +bundles=("${DOWNLOADS}"/VMware-*.bundle) +if [[ ${#bundles[@]} -ne 1 ]]; then + echo "Place exactly one VMware-*.bundle file in ${DOWNLOADS} and rerun this script." >&2 + exit 1 +fi -# Extract the tarball -tar -xzf workstation-17.5.0.tar.gz +bundle=${bundles[0]} +chmod u+x "$bundle" +sudo "$bundle" -# Change directory to the extracted folder -cd vmware-host-modules-workstation-17.5.0/ +wget -O "$MODULE_ARCHIVE" "$MODULE_URL" +tar -xzf "$MODULE_ARCHIVE" -C "$DOWNLOADS" -# Create tarballs for vmmon and vmnet -tar -cf vmmon.tar vmmon-only/ -tar -cf vmnet.tar vmnet-only/ +if [[ ! -d "$MODULE_DIR/vmmon-only" || ! -d "$MODULE_DIR/vmnet-only" ]]; then + echo "VMware host modules archive did not contain the expected directories." >&2 + exit 1 +fi -# Copy the tarballs to the VMware modules source directory -sudo cp -v vmmon.tar vmnet.tar /usr/lib/vmware/modules/source/ +tar -cf "${MODULE_DIR}/vmmon.tar" -C "$MODULE_DIR" vmmon-only +tar -cf "${MODULE_DIR}/vmnet.tar" -C "$MODULE_DIR" vmnet-only -# Run VMware modconfig to install all modules +sudo cp -v "${MODULE_DIR}/vmmon.tar" "${MODULE_DIR}/vmnet.tar" /usr/lib/vmware/modules/source/ sudo vmware-modconfig --console --install-all -echo -e "\033[0;32m=====================================" -echo -e "\033[1;32mThe Linux IT Guy - Linux Mint Scripts" -echo -e "\033[1;32mVMware Player Fix - Complete" -echo -e "\033[1;32mFire up Super->Administration->VMware Player to complete the installation." -echo -e "\033[0;32m=====================================\033[0m" +printf '=====================================\n' +printf 'The Linux IT Guy - Linux Mint Scripts\n' +printf 'VMware Player Fix - Complete\n' +printf 'Fire up Super->Administration->VMware Player to complete the installation.\n' +printf '=====================================\n' diff --git a/install-fastfetch.sh b/install-fastfetch.sh index 1451a01..0cd09f9 100644 --- a/install-fastfetch.sh +++ b/install-fastfetch.sh @@ -1,11 +1,17 @@ #!/usr/bin/env bash set -euo pipefail +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=toolbox-lib.sh +source "${SCRIPT_DIR}/toolbox-lib.sh" + printf '=====================================\n' printf 'The Linux IT Guy Toolbox\n' printf 'Installing fastfetch\n' printf '=====================================\n' +require_package_name "fastfetch" + if command -v apt-get >/dev/null 2>&1; then if command -v nala >/dev/null 2>&1; then sudo nala update @@ -13,13 +19,12 @@ if command -v apt-get >/dev/null 2>&1; then sudo nala install -f -y else sudo apt-get update - sudo apt-get install -y fastfetch + sudo apt-get install -y -- fastfetch fi elif command -v pacman >/dev/null 2>&1; then - sudo pacman -Syu --noconfirm - sudo pacman -S --noconfirm fastfetch + sudo pacman -S --needed --noconfirm -- fastfetch elif command -v dnf >/dev/null 2>&1; then - sudo dnf install -y fastfetch + sudo dnf install -y -- fastfetch else echo "Unsupported distribution." exit 1 diff --git a/install-nala.sh b/install-nala.sh index 927b6a4..66c772f 100644 --- a/install-nala.sh +++ b/install-nala.sh @@ -1,6 +1,10 @@ #!/usr/bin/env bash set -euo pipefail +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=toolbox-lib.sh +source "${SCRIPT_DIR}/toolbox-lib.sh" + printf '=====================================\n' printf 'The Linux IT Guy Toolbox\n' printf 'Configure nala mirrors\n' @@ -11,10 +15,12 @@ if ! command -v apt-get >/dev/null 2>&1; then exit 1 fi +require_package_name "nala" + if ! command -v nala >/dev/null 2>&1; then echo "Installing nala..." sudo apt-get update - sudo apt-get install -y nala + sudo apt-get install -y -- nala fi sudo nala fetch --auto diff --git a/install-powertop.sh b/install-powertop.sh index 5d71a20..5016e5a 100644 --- a/install-powertop.sh +++ b/install-powertop.sh @@ -1,11 +1,17 @@ #!/usr/bin/env bash set -euo pipefail +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=toolbox-lib.sh +source "${SCRIPT_DIR}/toolbox-lib.sh" + printf '=====================================\n' printf 'The Linux IT Guy Toolbox\n' printf 'Installing powertop\n' printf '=====================================\n' +require_package_name "powertop" + if command -v apt-get >/dev/null 2>&1; then if command -v nala >/dev/null 2>&1; then sudo nala update @@ -13,13 +19,12 @@ if command -v apt-get >/dev/null 2>&1; then sudo nala install -f -y else sudo apt-get update - sudo apt-get install -y powertop + sudo apt-get install -y -- powertop fi elif command -v pacman >/dev/null 2>&1; then - sudo pacman -Syu --noconfirm - sudo pacman -S --noconfirm powertop + sudo pacman -S --needed --noconfirm -- powertop elif command -v dnf >/dev/null 2>&1; then - sudo dnf install -y powertop + sudo dnf install -y -- powertop else echo "Unsupported distribution." exit 1 diff --git a/install-stacer.sh b/install-stacer.sh index e5cb79f..f5fc2ea 100644 --- a/install-stacer.sh +++ b/install-stacer.sh @@ -1,11 +1,17 @@ #!/usr/bin/env bash set -euo pipefail +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=toolbox-lib.sh +source "${SCRIPT_DIR}/toolbox-lib.sh" + printf '=====================================\n' printf 'The Linux IT Guy Toolbox\n' printf 'Installing stacer\n' printf '=====================================\n' +require_package_name "stacer" + if command -v apt-get >/dev/null 2>&1; then if command -v nala >/dev/null 2>&1; then sudo nala update @@ -13,13 +19,13 @@ if command -v apt-get >/dev/null 2>&1; then sudo nala install -f -y else sudo apt-get update - sudo apt-get install -y stacer + sudo apt-get install -y -- stacer fi elif command -v pacman >/dev/null 2>&1; then echo "Stacer is not in the official Arch repositories. Install it from the AUR if you want it available here." exit 1 elif command -v dnf >/dev/null 2>&1; then - sudo dnf install -y stacer + sudo dnf install -y -- stacer else echo "Unsupported distribution." exit 1 diff --git a/install-swapfix.sh b/install-swapfix.sh index 8db03c4..69254bf 100644 --- a/install-swapfix.sh +++ b/install-swapfix.sh @@ -6,18 +6,18 @@ printf 'The Linux IT Guy Toolbox\n' printf 'Applying SWAP Fix\n' printf '=====================================\n' -if command -v sysctl >/dev/null 2>&1; then - if [[ -f /etc/arch-release ]]; then - target_file="/etc/sysctl.d/99-swappiness.conf" - else - target_file="/etc/sysctl.d/99-toolbox-swappiness.conf" - fi - - echo "Writing vm.swappiness=10 to ${target_file}..." - printf 'vm.swappiness=10\n' | sudo tee "$target_file" >/dev/null - sudo sysctl --system >/dev/null - echo "SWAP fix applied. A reboot is optional, but not required." -else +if ! command -v sysctl >/dev/null 2>&1; then echo "sysctl is not available on this system." exit 1 fi + +if [[ -f /etc/arch-release ]]; then + target_file="/etc/sysctl.d/99-swappiness.conf" +else + target_file="/etc/sysctl.d/99-toolbox-swappiness.conf" +fi + +echo "Writing vm.swappiness=10 to ${target_file}..." +printf 'vm.swappiness=10\n' | sudo tee "$target_file" >/dev/null +sudo sysctl --system >/dev/null +echo "SWAP fix applied. A reboot is optional, but not required." diff --git a/install-tlp.sh b/install-tlp.sh index f24811c..54b4d8d 100644 --- a/install-tlp.sh +++ b/install-tlp.sh @@ -1,25 +1,32 @@ #!/usr/bin/env bash set -euo pipefail +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=toolbox-lib.sh +source "${SCRIPT_DIR}/toolbox-lib.sh" + printf '=====================================\n' printf 'The Linux IT Guy Toolbox\n' printf 'Installing TLP\n' printf '=====================================\n' +require_package_name "tlp" + if command -v apt-get >/dev/null 2>&1; then + require_package_name "tlp-rdw" if command -v nala >/dev/null 2>&1; then sudo nala update sudo nala install -y tlp tlp-rdw sudo nala install -f -y else sudo apt-get update - sudo apt-get install -y tlp tlp-rdw + sudo apt-get install -y -- tlp tlp-rdw fi elif command -v pacman >/dev/null 2>&1; then - sudo pacman -Syu --noconfirm - sudo pacman -S --noconfirm tlp + sudo pacman -S --needed --noconfirm -- tlp elif command -v dnf >/dev/null 2>&1; then - sudo dnf install -y tlp tlp-rdw + require_package_name "tlp-rdw" + sudo dnf install -y -- tlp tlp-rdw else echo "Unsupported distribution." exit 1 diff --git a/linux-it-guy-toolbox b/linux-it-guy-toolbox deleted file mode 100755 index 9669cf8..0000000 Binary files a/linux-it-guy-toolbox and /dev/null differ diff --git a/main.sh b/main.sh index 8543dd1..9bd3771 100644 --- a/main.sh +++ b/main.sh @@ -2,6 +2,9 @@ set -euo pipefail SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=toolbox-lib.sh +source "${SCRIPT_DIR}/toolbox-lib.sh" + ACTION="" APP_LABEL="" PACKAGE_NAME="" @@ -56,9 +59,17 @@ while [[ $# -gt 0 ]]; do esac done -if [[ -z "$ACTION" || -z "$APP_LABEL" ]]; then - usage >&2 - exit 1 +require_action "$ACTION" +require_label "$APP_LABEL" + +if [[ -n "$PACKAGE_NAME" ]]; then + require_package_name "$PACKAGE_NAME" +fi +if [[ -n "$FLATPAK_ID" ]]; then + require_flatpak_id "$FLATPAK_ID" +fi +if [[ -n "$EXEC_NAME" ]]; then + require_exec_name "$EXEC_NAME" fi if [[ -z "$PACKAGE_NAME" && -z "$FLATPAK_ID" ]]; then @@ -114,7 +125,7 @@ apt_install() { sudo nala install -y "$@" sudo nala install -f -y else - sudo apt-get install -y "$@" + sudo apt-get install -y -- "$@" sudo apt-get install -f -y fi } @@ -123,13 +134,14 @@ apt_remove() { if [[ "$APT_TOOL" == "nala" ]]; then sudo nala remove -y "$@" else - sudo apt-get remove -y "$@" + sudo apt-get remove -y -- "$@" sudo apt-get autoremove -y fi } native_installed() { local package=$1 + require_package_name "$package" case "$PACKAGE_MANAGER" in apt) dpkg-query -W -f='${Status}' "$package" 2>/dev/null | grep -q "install ok installed" @@ -148,7 +160,19 @@ native_installed() { flatpak_installed() { local app_id=$1 - flatpak info "$app_id" >/dev/null 2>&1 + require_flatpak_id "$app_id" + if ! command_exists flatpak; then + return 1 + fi + command flatpak info "$app_id" >/dev/null 2>&1 +} + +run_flatpak() { + if ! command_exists flatpak; then + echo "The flatpak command is not installed. Install the flatpak package and retry." >&2 + return 127 + fi + command flatpak "$@" } ensure_flatpak() { @@ -157,27 +181,40 @@ ensure_flatpak() { fi echo "Flatpak is not installed. Installing now..." + # sudo -n fails immediately instead of waiting on a password prompt when + # this script is launched from the GUI (stdin is not a terminal). case "$PACKAGE_MANAGER" in apt) - apt_update - apt_install flatpak + if [[ "$APT_TOOL" == "nala" ]]; then + sudo -n nala update + sudo -n nala install -y flatpak + else + sudo -n apt-get update + sudo -n apt-get install -y -- flatpak + fi ;; pacman) - sudo pacman -Syu --noconfirm - sudo pacman -S --noconfirm flatpak + sudo -n pacman -S --needed --noconfirm -- flatpak ;; dnf) - sudo dnf install -y flatpak + sudo -n dnf install -y -- flatpak ;; *) echo "Unsupported package manager." >&2 exit 1 ;; esac + + hash -r 2>/dev/null || true + if ! command_exists flatpak; then + echo "The flatpak command is still missing after package install." >&2 + exit 1 + fi } install_native() { local package=$1 + require_package_name "$package" if native_installed "$package"; then echo "$package is already installed. Skipping installation." return 0 @@ -191,21 +228,21 @@ install_native() { apt_install "$package" ;; pacman) - if [[ "$package" == "steam" ]] && ! grep -q '^\[multilib\]' /etc/pacman.conf; then - echo "Enabling multilib repository for Steam..." - sudo sed -i '/\[multilib\]/,/Include/s/^#//' /etc/pacman.conf + if [[ "$package" == "steam" ]] && ! arch_multilib_enabled; then + echo "Steam on Arch requires the multilib repository. Enable [multilib] in /etc/pacman.conf, then retry." >&2 + exit 1 fi - sudo pacman -Syu --noconfirm - sudo pacman -S --noconfirm "$package" + sudo pacman -S --needed --noconfirm -- "$package" ;; dnf) - sudo dnf install -y "$package" + sudo dnf install -y -- "$package" ;; esac } remove_native() { local package=$1 + require_package_name "$package" if ! native_installed "$package"; then echo "$package is not installed. Skipping removal." return 0 @@ -216,16 +253,17 @@ remove_native() { apt_remove "$package" ;; pacman) - sudo pacman -R --noconfirm "$package" + sudo pacman -R --noconfirm -- "$package" ;; dnf) - sudo dnf remove -y "$package" + sudo dnf remove -y -- "$package" ;; esac } install_flatpak_app() { local app_id=$1 + require_flatpak_id "$app_id" ensure_flatpak if flatpak_installed "$app_id"; then @@ -233,12 +271,13 @@ install_flatpak_app() { return 0 fi - flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo - flatpak install -y flathub "$app_id" + run_flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo + run_flatpak install --user -y -- flathub "$app_id" } remove_flatpak_app() { local app_id=$1 + require_flatpak_id "$app_id" ensure_flatpak if ! flatpak_installed "$app_id"; then @@ -246,7 +285,7 @@ remove_flatpak_app() { return 0 fi - flatpak uninstall -y "$app_id" + run_flatpak uninstall -y -- "$app_id" } print_header "${ACTION^} ${APP_LABEL}" diff --git a/src/catalog.rs b/src/catalog.rs new file mode 100644 index 0000000..0647b8e --- /dev/null +++ b/src/catalog.rs @@ -0,0 +1,675 @@ +//! App catalog loading and command construction. +//! +//! CSV rows and admin helper names are validated before they are turned into +//! argv arrays. Commands are never built as a single shell string. + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use serde::Deserialize; + +use crate::validate::{ + validate_action, validate_category, validate_exec_name, validate_flatpak_id, validate_label, + validate_notes, validate_package_name, validate_script_name, +}; + +#[derive(Clone, Debug, Deserialize)] +pub struct CsvAppEntry { + #[serde(rename = "Category")] + pub category: String, + #[serde(rename = "Label")] + pub label: String, + #[serde(rename = "Package Name")] + pub package_name: String, + #[serde(rename = "Flatpak ID")] + pub flatpak_id: String, + #[serde(rename = "Exec Name")] + pub exec_name: String, + #[serde(rename = "Notes")] + pub notes: String, +} + +#[derive(Clone, Debug)] +pub struct AppEntry { + pub category: String, + pub label: String, + pub package_name: String, + pub flatpak_id: String, + pub exec_name: String, + pub notes: String, +} + +impl AppEntry { + pub fn from_csv(entry: CsvAppEntry) -> Result { + let category = entry.category.trim(); + let label = entry.label.trim(); + let package_name = entry.package_name.trim(); + let flatpak_id = entry.flatpak_id.trim(); + let exec_name = entry.exec_name.trim(); + let notes = entry.notes.trim(); + + validate_category(category).map_err(|error| error.to_string())?; + validate_label(label).map_err(|error| error.to_string())?; + validate_notes(notes).map_err(|error| error.to_string())?; + + if package_name.is_empty() && flatpak_id.is_empty() { + return Err("at least one of package name or Flatpak ID is required".to_owned()); + } + if !package_name.is_empty() { + validate_package_name(package_name).map_err(|error| error.to_string())?; + } + if !flatpak_id.is_empty() { + validate_flatpak_id(flatpak_id).map_err(|error| error.to_string())?; + } + if !exec_name.is_empty() { + validate_exec_name(exec_name).map_err(|error| error.to_string())?; + } + + Ok(Self { + category: category.to_owned(), + label: label.to_owned(), + package_name: package_name.to_owned(), + flatpak_id: flatpak_id.to_owned(), + exec_name: exec_name.to_owned(), + notes: notes.to_owned(), + }) + } + + pub fn source_label(&self) -> &'static str { + if self.flatpak_id.is_empty() { + "native" + } else if self.package_name.is_empty() { + "flatpak" + } else { + "native + flatpak" + } + } + + pub fn try_command(&self, base_dir: &Path, action: &str) -> Result, String> { + validate_action(action).map_err(|error| error.to_string())?; + let script = resolve_helper_script(base_dir, "main.sh")?; + + let mut command = vec!["bash".to_owned(), script]; + command.extend(["--label".to_owned(), self.label.clone()]); + if !self.package_name.is_empty() { + command.extend(["--package".to_owned(), self.package_name.clone()]); + } + if !self.flatpak_id.is_empty() { + command.extend(["--flatpak".to_owned(), self.flatpak_id.clone()]); + } + if !self.exec_name.is_empty() { + command.extend(["--exec".to_owned(), self.exec_name.clone()]); + } + command.push(action.to_owned()); + Ok(command) + } +} + +#[derive(Clone, Debug)] +pub struct AdminTask { + pub category: String, + pub label: String, + pub script: String, +} + +impl AdminTask { + pub fn try_command(&self, base_dir: &Path) -> Result, String> { + let script = resolve_helper_script(base_dir, &self.script)?; + Ok(vec!["bash".to_owned(), script]) + } +} + +#[derive(Clone, Debug)] +pub struct Task { + pub description: String, + pub command: Vec, +} + +#[derive(Clone, Debug, Default)] +pub struct CatalogLoad { + pub apps: Vec, + pub warnings: Vec, + pub error: Option, +} + +pub fn resolve_helper_script(base_dir: &Path, name: &str) -> Result { + validate_script_name(name).map_err(|error| error.to_string())?; + + let path = base_dir.join(name); + if !path.is_file() { + return Err(format!("helper script {name} was not found")); + } + + let canonical = path + .canonicalize() + .map_err(|error| format!("cannot resolve helper script {name}: {error}"))?; + let base = base_dir + .canonicalize() + .map_err(|error| format!("cannot resolve app directory: {error}"))?; + + if !canonical.starts_with(&base) { + return Err(format!("helper script {name} is outside the app directory")); + } + + canonical + .to_str() + .map(ToOwned::to_owned) + .ok_or_else(|| format!("helper script {name} path is not valid UTF-8")) +} + +pub fn load_apps(base_dir: &Path) -> CatalogLoad { + let path = base_dir.join("apps_config.csv"); + if !path.is_file() { + return CatalogLoad { + error: Some(format!( + "App catalog not found at {}. Expected apps_config.csv beside the helper scripts (main.sh).", + path.display() + )), + ..CatalogLoad::default() + }; + } + + let mut reader = match csv::Reader::from_path(&path) { + Ok(reader) => reader, + Err(error) => { + return CatalogLoad { + error: Some(format!("Failed to open app catalog: {error}")), + ..CatalogLoad::default() + }; + } + }; + + let mut load = CatalogLoad::default(); + for (index, result) in reader.deserialize::().enumerate() { + let row = index + 2; + match result { + Ok(entry) => match AppEntry::from_csv(entry) { + Ok(app) => load.apps.push(app), + Err(error) => load + .warnings + .push(format!("Skipping catalog row {row}: {error}")), + }, + Err(error) => load + .warnings + .push(format!("Skipping catalog row {row}: {error}")), + } + } + + if load.apps.is_empty() && load.error.is_none() { + load.error = Some("App catalog did not contain any valid rows.".to_owned()); + } + + load +} + +pub fn admin_tasks() -> Vec { + [ + ( + "Power Management", + "Enable Bluetooth", + "enable-bluetooth.sh", + ), + ( + "Power Management", + "Disable Bluetooth", + "disable-bluetooth.sh", + ), + ("Power Management", "TLP (Laptops)", "install-tlp.sh"), + ("Power Management", "Powertop", "install-powertop.sh"), + ("System", "Update System", "update-system.sh"), + ( + "System", + "nala (rank mirrors) - Debian only", + "install-nala.sh", + ), + ("System", "Stacer", "install-stacer.sh"), + ("System", "SWAP Fix", "install-swapfix.sh"), + ("System", "Fastfetch", "install-fastfetch.sh"), + ] + .into_iter() + .map(|(category, label, script)| AdminTask { + category: category.to_owned(), + label: label.to_owned(), + script: script.to_owned(), + }) + .collect() +} + +pub fn display_command(command: &[String]) -> String { + command + .iter() + .map(|part| { + if part.chars().any(|c| c.is_ascii_whitespace()) { + format!("\"{part}\"") + } else { + part.clone() + } + }) + .collect::>() + .join(" ") +} + +const CATALOG_FILE: &str = "apps_config.csv"; +const HELPER_MARKER: &str = "main.sh"; + +/// Result of locating the directory that holds the catalog and helper scripts. +#[derive(Clone, Debug, Default)] +pub struct BaseDirDiscovery { + pub base_dir: PathBuf, + pub looked: Vec, + pub found: bool, +} + +impl BaseDirDiscovery { + pub fn not_found_message(&self) -> String { + if self.looked.is_empty() { + return "App catalog not found. Looked for apps_config.csv beside the helper scripts next to the executable, in parent folders, and in the current working directory.".to_owned(); + } + + let places = self + .looked + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join(", "); + format!( + "App catalog not found. Looked for apps_config.csv beside the helper scripts in: {places}" + ) + } +} + +pub fn find_base_dir() -> BaseDirDiscovery { + discover_base_dir( + std::env::current_exe().ok().as_deref(), + std::env::current_dir().ok().as_deref(), + ) +} + +/// Lookup order: directory of the executable, walk-up toward a repo root that +/// contains the catalog and helper scripts, the folder beside those helpers, +/// then the current working directory. +pub fn discover_base_dir(exe: Option<&Path>, cwd: Option<&Path>) -> BaseDirDiscovery { + let mut discovery = BaseDirDiscovery { + base_dir: PathBuf::from("."), + looked: Vec::new(), + found: false, + }; + let mut seen = HashSet::new(); + + let mut consider = |dir: &Path| -> bool { + let key = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf()); + if !seen.insert(key) { + return false; + } + discovery.looked.push(dir.to_path_buf()); + if catalog_present(dir) { + discovery.base_dir = dir.to_path_buf(); + discovery.found = true; + return true; + } + false + }; + + if let Some(exe) = exe { + // 1. Next to the executable (e.g. a release layout that ships the CSV). + if let Some(exe_dir) = exe.parent() { + if consider(exe_dir) { + return discovery; + } + + // 2. Walk up from the binary toward a checkout root. + for ancestor in exe_dir.ancestors().skip(1) { + if consider(ancestor) { + return discovery; + } + if ancestor.parent().is_none() { + break; + } + } + } + } + + // 3. Directory that already contains helper scripts (main.sh), if the + // catalog sits beside them but was not on the exe walk. + for candidate in helper_script_dirs(exe, cwd) { + if consider(&candidate) { + return discovery; + } + } + + // 4. Current working directory. + if let Some(cwd) = cwd + && consider(cwd) + { + return discovery; + } + + discovery +} + +fn helper_script_dirs(exe: Option<&Path>, cwd: Option<&Path>) -> Vec { + let mut dirs = Vec::new(); + let mut push_if_helpers = |dir: &Path| { + if dir.join(HELPER_MARKER).is_file() { + dirs.push(dir.to_path_buf()); + } + }; + + if let Some(exe) = exe + && let Some(exe_dir) = exe.parent() + { + push_if_helpers(exe_dir); + for ancestor in exe_dir.ancestors().skip(1) { + push_if_helpers(ancestor); + if ancestor.parent().is_none() { + break; + } + } + } + + if let Some(cwd) = cwd { + push_if_helpers(cwd); + } + + dirs +} + +fn catalog_present(dir: &Path) -> bool { + dir.join(CATALOG_FILE).is_file() && dir.join(HELPER_MARKER).is_file() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::io::Write; + use std::sync::atomic::{AtomicU64, Ordering}; + + static TEST_DIR_SEQ: AtomicU64 = AtomicU64::new(0); + + struct TempToolbox { + path: PathBuf, + } + + impl Drop for TempToolbox { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } + } + + fn sample_entry() -> AppEntry { + AppEntry { + category: "Browsers".to_owned(), + label: "Firefox".to_owned(), + package_name: "firefox".to_owned(), + flatpak_id: String::new(), + exec_name: "firefox".to_owned(), + notes: String::new(), + } + } + + fn temp_toolbox() -> TempToolbox { + let seq = TEST_DIR_SEQ.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "linux-it-guy-toolbox-test-{}-{}", + std::process::id(), + seq + )); + fs::create_dir_all(&path).expect("create temp toolbox dir"); + fs::write( + path.join("apps_config.csv"), + "Category,Label,Package Name,Flatpak ID,Exec Name,Notes\nBrowsers,Firefox,firefox,,firefox,\n", + ) + .unwrap(); + fs::write(path.join("main.sh"), "#!/bin/bash\n").unwrap(); + TempToolbox { path } + } + + #[test] + fn command_uses_argv_array_not_shell_string() { + let toolbox = temp_toolbox(); + let command = sample_entry() + .try_command(&toolbox.path, "install") + .unwrap(); + assert_eq!(command[0], "bash"); + assert!(command[1].ends_with("main.sh")); + assert_eq!( + command[2..], + [ + "--label", + "Firefox", + "--package", + "firefox", + "--exec", + "firefox", + "install" + ] + ); + assert!(!command.iter().any(|part| part.contains(';'))); + } + + #[test] + fn command_omits_empty_optional_targets() { + let toolbox = temp_toolbox(); + let mut entry = sample_entry(); + entry.package_name.clear(); + entry.flatpak_id = "org.mozilla.firefox".to_owned(); + let command = entry.try_command(&toolbox.path, "remove").unwrap(); + assert!(!command.contains(&"--package".to_owned())); + assert!(command.contains(&"--flatpak".to_owned())); + assert_eq!(command.last().map(String::as_str), Some("remove")); + } + + #[test] + fn command_rejects_unknown_action() { + let toolbox = temp_toolbox(); + assert!( + sample_entry() + .try_command(&toolbox.path, "upgrade") + .is_err() + ); + } + + #[test] + fn csv_row_rejects_invalid_package() { + let result = AppEntry::from_csv(CsvAppEntry { + category: "Browsers".into(), + label: "Evil".into(), + package_name: "-Syu".into(), + flatpak_id: String::new(), + exec_name: String::new(), + notes: String::new(), + }); + assert!(result.is_err()); + } + + #[test] + fn load_apps_skips_invalid_rows_and_keeps_valid_ones() { + let toolbox = temp_toolbox(); + let path = &toolbox.path; + let mut file = fs::File::create(path.join("apps_config.csv")).unwrap(); + writeln!( + file, + "Category,Label,Package Name,Flatpak ID,Exec Name,Notes" + ) + .unwrap(); + writeln!(file, "Browsers,Firefox,firefox,,firefox,").unwrap(); + writeln!(file, "Browsers,Evil,-Syu,,evil,").unwrap(); + writeln!( + file, + "Browsers,Brave,,com.brave.Browser,brave,Privacy-focused browser" + ) + .unwrap(); + drop(file); + fs::write(path.join("main.sh"), "#!/bin/bash\n").unwrap(); + + let load = load_apps(path); + assert_eq!(load.apps.len(), 2); + assert_eq!(load.apps[0].label, "Firefox"); + assert_eq!(load.apps[1].label, "Brave"); + assert_eq!(load.warnings.len(), 1); + assert!(load.error.is_none()); + } + + #[test] + fn resolve_helper_script_rejects_unknown_and_escaped_names() { + let toolbox = temp_toolbox(); + assert!(resolve_helper_script(&toolbox.path, "main.sh").is_ok()); + assert!(resolve_helper_script(&toolbox.path, "../main.sh").is_err()); + assert!(resolve_helper_script(&toolbox.path, "not-real.sh").is_err()); + assert!(resolve_helper_script(&toolbox.path, "enable-bluetooth.sh").is_err()); + } + + #[test] + fn admin_command_is_bash_plus_allowlisted_script() { + let toolbox = temp_toolbox(); + let path = toolbox.path.clone(); + fs::write(path.join("update-system.sh"), "#!/bin/bash\n").unwrap(); + let task = AdminTask { + category: "System".into(), + label: "Update System".into(), + script: "update-system.sh".into(), + }; + let command = task.try_command(&path).unwrap(); + assert_eq!(command[0], "bash"); + assert!(command[1].ends_with("update-system.sh")); + assert_eq!(command.len(), 2); + } + + #[test] + fn display_command_quotes_whitespace() { + assert_eq!( + display_command(&["bash".into(), "/tmp/My Scripts/main.sh".into()]), + "bash \"/tmp/My Scripts/main.sh\"" + ); + } + + #[test] + fn shipped_catalog_loads_without_warnings() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let load = load_apps(&root); + assert!(load.error.is_none(), "{:?}", load.error); + assert!( + load.warnings.is_empty(), + "unexpected catalog warnings: {:?}", + load.warnings + ); + assert!(!load.apps.is_empty()); + } + + fn empty_dir(label: &str) -> PathBuf { + let seq = TEST_DIR_SEQ.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "linux-it-guy-toolbox-lookup-{}-{}-{}", + std::process::id(), + seq, + label + )); + fs::create_dir_all(&path).expect("create empty lookup dir"); + path + } + + fn write_catalog_files(dir: &Path) { + fs::write( + dir.join("apps_config.csv"), + "Category,Label,Package Name,Flatpak ID,Exec Name,Notes\nBrowsers,Firefox,firefox,,firefox,\n", + ) + .unwrap(); + fs::write(dir.join("main.sh"), "#!/bin/bash\n").unwrap(); + } + + #[test] + fn discover_prefers_directory_beside_the_executable() { + let toolbox = temp_toolbox(); + let exe = toolbox.path.join("linux-it-guy-toolbox"); + fs::write(&exe, []).unwrap(); + let cwd = empty_dir("cwd-ignored"); + write_catalog_files(&cwd); + + let discovery = discover_base_dir(Some(&exe), Some(&cwd)); + assert!(discovery.found); + assert_eq!( + discovery.base_dir.canonicalize().unwrap(), + toolbox.path.canonicalize().unwrap() + ); + let _ = fs::remove_dir_all(cwd); + } + + #[test] + fn discover_walks_up_from_target_release_to_repo_root() { + let toolbox = temp_toolbox(); + let release_dir = toolbox.path.join("target").join("release"); + fs::create_dir_all(&release_dir).unwrap(); + let exe = release_dir.join("linux-it-guy-toolbox"); + fs::write(&exe, []).unwrap(); + let cwd = empty_dir("other-cwd"); + + let discovery = discover_base_dir(Some(&exe), Some(&cwd)); + assert!(discovery.found); + assert_eq!( + discovery.base_dir.canonicalize().unwrap(), + toolbox.path.canonicalize().unwrap() + ); + assert!(discovery.looked.iter().any(|path| path == &release_dir)); + assert!(discovery.looked.iter().any(|path| path == &toolbox.path)); + let _ = fs::remove_dir_all(cwd); + } + + #[test] + fn discover_uses_directory_beside_helper_scripts() { + let root = empty_dir("helpers-root"); + write_catalog_files(&root); + let nested = root.join("nested").join("bin"); + fs::create_dir_all(&nested).unwrap(); + let exe = nested.join("toolbox"); + fs::write(&exe, []).unwrap(); + let cwd = empty_dir("helpers-cwd"); + + let discovery = discover_base_dir(Some(&exe), Some(&cwd)); + assert!(discovery.found); + assert_eq!( + discovery.base_dir.canonicalize().unwrap(), + root.canonicalize().unwrap() + ); + let _ = fs::remove_dir_all(root); + let _ = fs::remove_dir_all(cwd); + } + + #[test] + fn discover_falls_back_to_current_working_directory() { + let cwd = empty_dir("cwd-catalog"); + write_catalog_files(&cwd); + let exe_dir = empty_dir("exe-without-catalog"); + let exe = exe_dir.join("linux-it-guy-toolbox"); + fs::write(&exe, []).unwrap(); + + let discovery = discover_base_dir(Some(&exe), Some(&cwd)); + assert!(discovery.found); + assert_eq!( + discovery.base_dir.canonicalize().unwrap(), + cwd.canonicalize().unwrap() + ); + let _ = fs::remove_dir_all(cwd); + let _ = fs::remove_dir_all(exe_dir); + } + + #[test] + fn discover_reports_every_place_it_looked_when_missing() { + let exe_dir = empty_dir("missing-exe"); + let nested = exe_dir.join("target").join("release"); + fs::create_dir_all(&nested).unwrap(); + let exe = nested.join("linux-it-guy-toolbox"); + fs::write(&exe, []).unwrap(); + let cwd = empty_dir("missing-cwd"); + + let discovery = discover_base_dir(Some(&exe), Some(&cwd)); + assert!(!discovery.found); + assert!(discovery.looked.iter().any(|path| path == &nested)); + assert!(discovery.looked.iter().any(|path| path == &cwd)); + let message = discovery.not_found_message(); + assert!(message.contains("Looked for apps_config.csv beside the helper scripts")); + assert!(!message.contains("./apps_config.csv")); + let _ = fs::remove_dir_all(exe_dir); + let _ = fs::remove_dir_all(cwd); + } +} diff --git a/src/main.rs b/src/main.rs index 59cc76a..33d5aea 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,11 @@ +mod catalog; +mod runner; +mod system; +mod validate; + use std::{ collections::{BTreeMap, HashMap, HashSet}, - env, fs, - path::{Path, PathBuf}, - process::{Command, Stdio}, + path::PathBuf, sync::mpsc::{self, Receiver, Sender}, thread, }; @@ -13,7 +16,13 @@ use eframe::egui::{ RichText, ScrollArea, Sense, Stroke, StrokeKind, TextEdit, TextureHandle, TextureOptions, Ui, Vec2, pos2, vec2, }; -use serde::Deserialize; + +use catalog::{AdminTask, AppEntry, CatalogLoad, Task, admin_tasks, find_base_dir, load_apps}; +use runner::{RunnerMessage, run_tasks}; +use system::{ + command_output, detect_package_manager, distro_name, env_or_unknown, strip_ansi, uptime, +}; +use validate::zeroize_string; fn main() -> eframe::Result { let options = eframe::NativeOptions { @@ -31,82 +40,6 @@ fn main() -> eframe::Result { ) } -#[derive(Clone, Debug, Deserialize)] -struct CsvAppEntry { - #[serde(rename = "Category")] - category: String, - #[serde(rename = "Label")] - label: String, - #[serde(rename = "Package Name")] - package_name: String, - #[serde(rename = "Flatpak ID")] - flatpak_id: String, - #[serde(rename = "Exec Name")] - exec_name: String, - #[serde(rename = "Notes")] - notes: String, -} - -#[derive(Clone, Debug)] -struct AppEntry { - category: String, - label: String, - package_name: String, - flatpak_id: String, - exec_name: String, - notes: String, -} - -impl AppEntry { - fn source_label(&self) -> &'static str { - if self.flatpak_id.is_empty() { - "native" - } else if self.package_name.is_empty() { - "flatpak" - } else { - "native + flatpak" - } - } - - fn command(&self, base_dir: &Path, action: &str) -> Vec { - vec![ - "bash".to_owned(), - base_dir.join("main.sh").display().to_string(), - "--label".to_owned(), - self.label.clone(), - "--package".to_owned(), - self.package_name.clone(), - "--flatpak".to_owned(), - self.flatpak_id.clone(), - "--exec".to_owned(), - self.exec_name.clone(), - action.to_owned(), - ] - } -} - -#[derive(Clone, Debug)] -struct AdminTask { - category: String, - label: String, - script: String, -} - -impl AdminTask { - fn command(&self, base_dir: &Path) -> Vec { - vec![ - "bash".to_owned(), - base_dir.join(&self.script).display().to_string(), - ] - } -} - -#[derive(Clone, Debug)] -struct Task { - description: String, - command: Vec, -} - #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum Page { Install, @@ -149,12 +82,6 @@ struct ToolboxApp { distro_icons: HashMap<&'static str, TextureHandle>, } -#[derive(Debug)] -enum RunnerMessage { - Log(String), - Done, -} - impl ToolboxApp { fn new(cc: &eframe::CreationContext<'_>) -> Self { install_fonts(&cc.egui_ctx); @@ -162,15 +89,36 @@ impl ToolboxApp { let icons = load_icons(&cc.egui_ctx); let distro_icons = load_distro_icons(&cc.egui_ctx); - let base_dir = find_base_dir(); - let apps = load_apps(&base_dir); + let discovery = find_base_dir(); + let catalog = if discovery.found { + load_apps(&discovery.base_dir) + } else { + CatalogLoad { + error: Some(discovery.not_found_message()), + ..CatalogLoad::default() + } + }; + let base_dir = discovery.base_dir; let distro_name = distro_name(); let package_manager = detect_package_manager(); + let mut log = "Process logs will appear here...".to_owned(); + if let Some(error) = &catalog.error { + log = format!("[ERROR] {error}"); + } + for warning in &catalog.warnings { + if log == "Process logs will appear here..." { + log.clear(); + } + log.push('\n'); + log.push_str("[WARN] "); + log.push_str(warning); + } + Self { base_dir, page: Page::Install, - apps, + apps: catalog.apps, admin_tasks: admin_tasks(), install_selected: HashSet::new(), remove_selected: HashSet::new(), @@ -179,7 +127,7 @@ impl ToolboxApp { category_filter: None, distro_name, package_manager, - log: "Process logs will appear here...".to_owned(), + log, password: String::new(), show_password_modal: false, is_running: false, @@ -204,37 +152,51 @@ impl ToolboxApp { install + remove + admin } - fn selected_tasks(&self) -> Vec { + fn selected_tasks(&self) -> Result, Vec> { let mut tasks = Vec::new(); + let mut errors = Vec::new(); for index in &self.install_selected { if let Some(entry) = self.apps.get(*index) { - tasks.push(Task { - description: format!("Installing {}", entry.label), - command: entry.command(&self.base_dir, "install"), - }); + match entry.try_command(&self.base_dir, "install") { + Ok(command) => tasks.push(Task { + description: format!("Installing {}", entry.label), + command, + }), + Err(error) => errors.push(format!("{}: {error}", entry.label)), + } } } for index in &self.remove_selected { if let Some(entry) = self.apps.get(*index) { - tasks.push(Task { - description: format!("Removing {}", entry.label), - command: entry.command(&self.base_dir, "remove"), - }); + match entry.try_command(&self.base_dir, "remove") { + Ok(command) => tasks.push(Task { + description: format!("Removing {}", entry.label), + command, + }), + Err(error) => errors.push(format!("{}: {error}", entry.label)), + } } } for index in &self.admin_selected { if let Some(task) = self.admin_tasks.get(*index) { - tasks.push(Task { - description: format!("Running {}", task.label), - command: task.command(&self.base_dir), - }); + match task.try_command(&self.base_dir) { + Ok(command) => tasks.push(Task { + description: format!("Running {}", task.label), + command, + }), + Err(error) => errors.push(format!("{}: {error}", task.label)), + } } } - tasks + if errors.is_empty() { + Ok(tasks) + } else { + Err(errors) + } } fn clear_selection(&mut self) { @@ -267,10 +229,10 @@ impl ToolboxApp { } fn app_matches_filter(&self, entry: &AppEntry) -> bool { - if let Some(category) = &self.category_filter { - if &entry.category != category { - return false; - } + if let Some(category) = &self.category_filter + && &entry.category != category + { + return false; } let needle = self.search.trim().to_lowercase(); @@ -285,13 +247,27 @@ impl ToolboxApp { } fn run_selected(&mut self, ctx: &Context) { - let tasks = self.selected_tasks(); - if tasks.is_empty() || self.is_running { + if self.is_running { return; } + let tasks = match self.selected_tasks() { + Ok(tasks) if !tasks.is_empty() => tasks, + Ok(_) => return, + Err(errors) => { + self.log = "[ERROR] Refusing to run tasks with invalid identifiers.".to_owned(); + for error in errors { + self.log.push('\n'); + self.log.push_str(&error); + } + self.log_revision = self.log_revision.saturating_add(1); + zeroize_string(&mut self.password); + return; + } + }; + let (tx, rx) = mpsc::channel(); - let password = self.password.clone(); + let password = std::mem::take(&mut self.password); self.log = format!("Queued {} task(s)...", tasks.len()); self.log_revision = self.log_revision.saturating_add(1); self.is_running = true; @@ -325,7 +301,7 @@ impl ToolboxApp { if done { self.is_running = false; - self.password.clear(); + zeroize_string(&mut self.password); self.tx = None; self.rx = None; self.log.push_str("\n\n[DONE] All tasks finished."); @@ -417,7 +393,7 @@ impl ToolboxApp { } fn install_remove_page(&mut self, ui: &mut Ui, install: bool) { - self.toolbar(ui, install); + self.toolbar(ui); ui.add_space(10.0); let mut categories: BTreeMap> = BTreeMap::new(); @@ -483,7 +459,7 @@ impl ToolboxApp { ui.add_space(6.0); } - fn toolbar(&mut self, ui: &mut Ui, install: bool) { + fn toolbar(&mut self, ui: &mut Ui) { toolbar_frame().show(ui, |ui| { ui.horizontal(|ui| { let actions_width = 420.0; @@ -521,15 +497,7 @@ impl ToolboxApp { if flat_text_button(ui, "Select All", accent_blue(), 92.0).clicked() { self.select_visible_apps(true); } - search_field( - ui, - &mut self.search, - if install { - "Filter apps..." - } else { - "Filter apps..." - }, - ); + search_field(ui, &mut self.search, "Filter apps..."); }); }); }); @@ -756,18 +724,13 @@ impl ToolboxApp { fn system_info_page(&mut self, ui: &mut Ui) { let rows = [ ("OS", self.distro_name.clone()), - ("Host", command_output("hostname")), - ("Kernel", command_output("uname -r")), + ("Host", command_output("hostname", &[])), + ("Kernel", command_output("uname", &["-r"])), ("Uptime", uptime()), - ( - "Shell", - env::var("SHELL").unwrap_or_else(|_| "Unknown".to_owned()), - ), + ("Shell", env_or_unknown(&["SHELL"])), ( "DE/WM", - env::var("XDG_CURRENT_DESKTOP") - .or_else(|_| env::var("DESKTOP_SESSION")) - .unwrap_or_else(|_| "Unknown".to_owned()), + env_or_unknown(&["XDG_CURRENT_DESKTOP", "DESKTOP_SESSION"]), ), ("Package Manager", self.package_manager.clone()), ("App Directory", self.base_dir.display().to_string()), @@ -855,7 +818,7 @@ impl ToolboxApp { let showing_placeholder = self.log == "Process logs will appear here..."; Frame::new() .fill(Color32::from_rgb(8, 10, 11)) - .stroke(Stroke::new(1.0, Color32::from_rgb(42, 48, 52))) + .stroke(Stroke::new(1.0_f32, Color32::from_rgb(42, 48, 52))) .inner_margin(8.0) .show(ui, |ui| { if showing_placeholder { @@ -905,7 +868,7 @@ impl ToolboxApp { ui.add_space(10.0); ui.horizontal(|ui| { if ui.button("Cancel").clicked() { - self.password.clear(); + zeroize_string(&mut self.password); self.show_password_modal = false; } if ui @@ -931,7 +894,7 @@ impl eframe::App for ToolboxApp { .frame( Frame::new() .fill(Color32::from_rgb(21, 25, 30)) - .stroke(Stroke::new(1.0, border())) + .stroke(Stroke::new(1.0_f32, border())) .inner_margin(14.0), ) .show_inside(ui, |ui| self.sidebar(ui)); @@ -940,7 +903,7 @@ impl eframe::App for ToolboxApp { .frame( Frame::new() .fill(background()) - .stroke(Stroke::new(1.0, border())) + .stroke(Stroke::new(1.0_f32, border())) .inner_margin(egui::Margin::symmetric(16, 14)), ) .show_inside(ui, |ui| self.header(ui)); @@ -950,7 +913,7 @@ impl eframe::App for ToolboxApp { .frame( Frame::new() .fill(background()) - .stroke(Stroke::new(1.0, border())) + .stroke(Stroke::new(1.0_f32, border())) .inner_margin(egui::Margin::symmetric(14, 10)), ) .show_inside(ui, |ui| { @@ -1145,7 +1108,7 @@ fn apply_theme(ctx: &Context) { style.visuals.window_fill = background(); style.visuals.panel_fill = background(); style.visuals.override_text_color = Some(primary_text()); - style.visuals.widgets.noninteractive.fg_stroke = Stroke::new(1.0, primary_text()); + style.visuals.widgets.noninteractive.fg_stroke = Stroke::new(1.0_f32, primary_text()); style.visuals.widgets.inactive.bg_fill = Color32::from_rgb(32, 38, 44); style.visuals.widgets.hovered.bg_fill = Color32::from_rgb(42, 50, 58); style.visuals.widgets.active.bg_fill = accent_blue(); @@ -1191,7 +1154,7 @@ fn nav_aux_button(ui: &mut Ui, label: &str, symbol: &str) -> egui::Response { } let icon = Rect::from_min_size(rect.min + vec2(20.0, 9.0), vec2(22.0, 22.0)); ui.painter() - .circle_stroke(icon.center(), 10.0, Stroke::new(1.5, subtle_text())); + .circle_stroke(icon.center(), 10.0, Stroke::new(1.5_f32, subtle_text())); ui.painter().text( icon.center(), Align2::CENTER_CENTER, @@ -1220,7 +1183,7 @@ fn chip(ui: &mut Ui, label: &str, selected: bool, icon: Option<&TextureHandle>) ui.painter().rect_stroke( rect, 7.0, - Stroke::new(1.0, if selected { accent_blue() } else { border() }), + Stroke::new(1.0_f32, if selected { accent_blue() } else { border() }), StrokeKind::Inside, ); distro_mark( @@ -1284,7 +1247,7 @@ fn search_field(ui: &mut Ui, search: &mut String, hint: &str) { let search_fill = Color32::from_rgb(31, 35, 41); let response = Frame::new() .fill(search_fill) - .stroke(Stroke::new(1.0, Color32::from_rgb(55, 63, 72))) + .stroke(Stroke::new(1.0_f32, Color32::from_rgb(55, 63, 72))) .corner_radius(5.0) .inner_margin(egui::Margin::symmetric(8, 0)) .show(ui, |ui| { @@ -1312,13 +1275,13 @@ fn search_field(ui: &mut Ui, search: &mut String, hint: &str) { fn paint_search_icon(painter: &Painter, rect: Rect) { let center = pos2(rect.left() + 8.0, rect.center().y - 1.0); - painter.circle_stroke(center, 5.0, Stroke::new(1.4, subtle_text())); + painter.circle_stroke(center, 5.0, Stroke::new(1.4_f32, subtle_text())); painter.line_segment( [ pos2(center.x + 4.0, center.y + 4.0), pos2(center.x + 9.0, center.y + 9.0), ], - Stroke::new(1.4, subtle_text()), + Stroke::new(1.4_f32, subtle_text()), ); } @@ -1347,28 +1310,28 @@ fn section_header(ui: &mut Ui, title: &str, count: usize) { fn toolbar_frame() -> Frame { Frame::new() .fill(Color32::from_rgb(24, 29, 34)) - .stroke(Stroke::new(1.0, border())) + .stroke(Stroke::new(1.0_f32, border())) .inner_margin(egui::Margin::symmetric(12, 7)) } fn summary_frame() -> Frame { Frame::new() .fill(Color32::from_rgb(25, 30, 36)) - .stroke(Stroke::new(1.0, border())) + .stroke(Stroke::new(1.0_f32, border())) .inner_margin(egui::Margin::symmetric(12, 8)) } fn log_frame() -> Frame { Frame::new() .fill(Color32::from_rgb(20, 24, 29)) - .stroke(Stroke::new(1.0, border())) + .stroke(Stroke::new(1.0_f32, border())) .inner_margin(egui::Margin::symmetric(10, 8)) } fn panel_frame() -> Frame { Frame::new() .fill(panel()) - .stroke(Stroke::new(1.0, border())) + .stroke(Stroke::new(1.0_f32, border())) .inner_margin(12.0) } @@ -1384,7 +1347,7 @@ fn paint_row_background(painter: &Painter, rect: Rect, hovered: bool, selected: painter.rect_stroke( rect, 4.0, - Stroke::new(1.0, if selected { accent_blue() } else { border() }), + Stroke::new(1.0_f32, if selected { accent_blue() } else { border() }), StrokeKind::Inside, ); } @@ -1403,7 +1366,7 @@ fn paint_checkbox(painter: &Painter, rect: Rect, selected: bool) { rect, 3.0, Stroke::new( - 1.0, + 1.0_f32, if selected { accent_blue_light() } else { @@ -1416,8 +1379,8 @@ fn paint_checkbox(painter: &Painter, rect: Rect, selected: bool) { let a = pos2(rect.left() + 4.0, rect.center().y); let b = pos2(rect.left() + 7.0, rect.bottom() - 5.0); let c = pos2(rect.right() - 4.0, rect.top() + 5.0); - painter.line_segment([a, b], Stroke::new(2.0, Color32::WHITE)); - painter.line_segment([b, c], Stroke::new(2.0, Color32::WHITE)); + painter.line_segment([a, b], Stroke::new(2.0_f32, Color32::WHITE)); + painter.line_segment([b, c], Stroke::new(2.0_f32, Color32::WHITE)); } } @@ -1442,7 +1405,7 @@ fn paint_app_icon(painter: &Painter, rect: Rect, label: &str, icon: Option<&Text painter.circle_stroke( rect.center(), rect.width() / 2.0, - Stroke::new(1.0, Color32::from_white_alpha(80)), + Stroke::new(1.0_f32, Color32::from_white_alpha(80)), ); painter.text( rect.center(), @@ -1471,10 +1434,10 @@ fn paint_admin_icon(painter: &Painter, rect: Rect, label: &str) { painter.circle_stroke( rect.center(), rect.width() / 2.0, - Stroke::new(1.0, Color32::from_white_alpha(80)), + Stroke::new(1.0_f32, Color32::from_white_alpha(80)), ); - let stroke = Stroke::new(1.7, Color32::WHITE); + let stroke = Stroke::new(1.7_f32, Color32::WHITE); let c = rect.center(); match label { "Enable Bluetooth" | "Disable Bluetooth" => { @@ -1510,7 +1473,7 @@ fn paint_admin_icon(painter: &Painter, rect: Rect, label: &str) { pos2(rect.left() + 6.0, rect.bottom() - 6.0), pos2(rect.right() - 6.0, rect.top() + 6.0), ], - Stroke::new(2.1, Color32::WHITE), + Stroke::new(2.1_f32, Color32::WHITE), ); } } @@ -1622,7 +1585,7 @@ fn paint_badge(painter: &Painter, center: Pos2, label: &str) { Color32::from_rgb(76, 120, 189) }; painter.rect_filled(rect, 5.0, fill); - painter.rect_stroke(rect, 5.0, Stroke::new(1.0, stroke), StrokeKind::Inside); + painter.rect_stroke(rect, 5.0, Stroke::new(1.0_f32, stroke), StrokeKind::Inside); painter.text( rect.center(), Align2::CENTER_CENTER, @@ -1646,7 +1609,7 @@ fn toolbox_icon(ui: &mut Ui, size: f32) { painter.rect_stroke( body, 3.0, - Stroke::new(1.8, primary_text()), + Stroke::new(1.8_f32, primary_text()), StrokeKind::Inside, ); painter.line_segment( @@ -1654,7 +1617,7 @@ fn toolbox_icon(ui: &mut Ui, size: f32) { pos2(body.left(), body.top() + 6.0), pos2(body.right(), body.top() + 6.0), ], - Stroke::new(1.4, primary_text()), + Stroke::new(1.4_f32, primary_text()), ); painter.rect_stroke( Rect::from_min_size( @@ -1662,7 +1625,7 @@ fn toolbox_icon(ui: &mut Ui, size: f32) { vec2(10.0, 6.0), ), 2.0, - Stroke::new(1.5, primary_text()), + Stroke::new(1.5_f32, primary_text()), StrokeKind::Inside, ); } @@ -1677,7 +1640,7 @@ fn page_icon(ui: &mut Ui, page: Page, size: f32, filled: bool) { } fn paint_page_symbol(painter: &Painter, rect: Rect, page: Page, color: Color32) { - let stroke = Stroke::new(1.8, color); + let stroke = Stroke::new(1.8_f32, color); match page { Page::Install => { painter.line_segment( @@ -1782,7 +1745,7 @@ fn paint_page_symbol(painter: &Painter, rect: Rect, page: Page, color: Color32) fn category_icon(ui: &mut Ui, title: &str) { let (rect, _) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::hover()); let painter = ui.painter(); - let stroke = Stroke::new(1.4, subtle_text()); + let stroke = Stroke::new(1.4_f32, subtle_text()); match title { "Browsers" => { painter.circle_stroke(rect.center(), 9.0, stroke); @@ -1840,7 +1803,7 @@ fn small_terminal_icon(ui: &mut Ui) { ui.painter().rect_stroke( rect.shrink(2.0), 2.0, - Stroke::new(1.0, subtle_text()), + Stroke::new(1.0_f32, subtle_text()), StrokeKind::Inside, ); ui.painter().text( @@ -2070,227 +2033,3 @@ fn page_subtitle(page: Page) -> &'static str { Page::SystemInfo => "Inspect local system and runtime details.", } } - -fn find_base_dir() -> PathBuf { - if let Ok(exe) = env::current_exe() { - if let Some(dir) = exe.parent() { - if dir.join("apps_config.csv").exists() { - return dir.to_path_buf(); - } - } - } - - env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) -} - -fn load_apps(base_dir: &Path) -> Vec { - let path = base_dir.join("apps_config.csv"); - let Ok(mut reader) = csv::Reader::from_path(path) else { - return Vec::new(); - }; - - reader - .deserialize::() - .filter_map(Result::ok) - .filter(|entry| !entry.category.trim().is_empty() && !entry.label.trim().is_empty()) - .map(|entry| AppEntry { - category: entry.category.trim().to_owned(), - label: entry.label.trim().to_owned(), - package_name: entry.package_name.trim().to_owned(), - flatpak_id: entry.flatpak_id.trim().to_owned(), - exec_name: entry.exec_name.trim().to_owned(), - notes: entry.notes.trim().to_owned(), - }) - .collect() -} - -fn admin_tasks() -> Vec { - [ - ( - "Power Management", - "Enable Bluetooth", - "enable-bluetooth.sh", - ), - ( - "Power Management", - "Disable Bluetooth", - "disable-bluetooth.sh", - ), - ("Power Management", "TLP (Laptops)", "install-tlp.sh"), - ("Power Management", "Powertop", "install-powertop.sh"), - ("System", "Update System", "update-system.sh"), - ( - "System", - "nala (rank mirrors) - Debian only", - "install-nala.sh", - ), - ("System", "Stacer", "install-stacer.sh"), - ("System", "SWAP Fix", "install-swapfix.sh"), - ("System", "Fastfetch", "install-fastfetch.sh"), - ] - .into_iter() - .map(|(category, label, script)| AdminTask { - category: category.to_owned(), - label: label.to_owned(), - script: script.to_owned(), - }) - .collect() -} - -fn run_tasks(tasks: Vec, password: String, tx: Sender) { - for task in tasks { - let _ = tx.send(RunnerMessage::Log(format!( - "[INFO] Starting: {}", - task.description - ))); - let _ = tx.send(RunnerMessage::Log(format!( - "Command: sudo -S {}", - task.command.join(" ") - ))); - - let Some((program, args)) = task.command.split_first() else { - let _ = tx.send(RunnerMessage::Log("[ERROR] Empty command.".to_owned())); - continue; - }; - - let output = Command::new("sudo") - .arg("-S") - .arg(program) - .args(args) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .and_then(|mut child| { - if let Some(stdin) = child.stdin.as_mut() { - use std::io::Write; - stdin.write_all(format!("{password}\n").as_bytes())?; - } - child.wait_with_output() - }); - - match output { - Ok(output) => { - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - for line in stdout.lines().chain(stderr.lines()) { - let _ = tx.send(RunnerMessage::Log(line.to_owned())); - } - if output.status.success() { - let _ = tx.send(RunnerMessage::Log(format!( - "[SUCCESS] {} completed successfully.", - task.description - ))); - } else { - let code = output - .status - .code() - .map_or_else(|| "unknown".to_owned(), |code| code.to_string()); - let _ = tx.send(RunnerMessage::Log(format!( - "[ERROR] {} failed with return code {code}.", - task.description - ))); - } - } - Err(error) => { - let _ = tx.send(RunnerMessage::Log(format!( - "[EXCEPTION] Failed to run {}: {error}", - task.description - ))); - } - } - } - - let _ = tx.send(RunnerMessage::Done); -} - -fn distro_name() -> String { - let Ok(contents) = fs::read_to_string("/etc/os-release") else { - return "Linux".to_owned(); - }; - - let mut fallback = None; - for line in contents.lines() { - if let Some(value) = line.strip_prefix("PRETTY_NAME=") { - return value.trim_matches('"').to_owned(); - } - if let Some(value) = line.strip_prefix("NAME=") { - fallback = Some(value.trim_matches('"').to_owned()); - } - } - fallback.unwrap_or_else(|| "Linux".to_owned()) -} - -fn detect_package_manager() -> String { - for manager in ["apt-get", "pacman", "dnf"] { - if Command::new("sh") - .arg("-c") - .arg(format!("command -v {manager} >/dev/null 2>&1")) - .status() - .map(|status| status.success()) - .unwrap_or(false) - { - return manager.to_owned(); - } - } - "unknown".to_owned() -} - -fn command_output(command: &str) -> String { - let mut parts = command.split_whitespace(); - let Some(program) = parts.next() else { - return "Unknown".to_owned(); - }; - Command::new(program) - .args(parts) - .output() - .ok() - .and_then(|output| String::from_utf8(output.stdout).ok()) - .map(|value| value.trim().to_owned()) - .filter(|value| !value.is_empty()) - .unwrap_or_else(|| "Unknown".to_owned()) -} - -fn uptime() -> String { - let Ok(contents) = fs::read_to_string("/proc/uptime") else { - return "Unknown".to_owned(); - }; - let Ok(seconds) = contents - .split_whitespace() - .next() - .unwrap_or("0") - .parse::() - else { - return "Unknown".to_owned(); - }; - - let total = seconds as u64; - let days = total / 86_400; - let hours = (total % 86_400) / 3_600; - let minutes = (total % 3_600) / 60; - - match (days, hours) { - (0, 0) => format!("{minutes}m"), - (0, _) => format!("{hours}h {minutes}m"), - _ => format!("{days}d {hours}h {minutes}m"), - } -} - -fn strip_ansi(input: &str) -> String { - let mut output = String::with_capacity(input.len()); - let mut chars = input.chars().peekable(); - - while let Some(ch) = chars.next() { - if ch == '\u{1b}' { - for next in chars.by_ref() { - if next.is_ascii_alphabetic() { - break; - } - } - } else { - output.push(ch); - } - } - - output -} diff --git a/src/runner.rs b/src/runner.rs new file mode 100644 index 0000000..c6bd588 --- /dev/null +++ b/src/runner.rs @@ -0,0 +1,455 @@ +//! Task execution with least privilege and secret handling. +//! +//! Helper scripts run as the invoking user. Privileged work stays inside the +//! scripts' own `sudo` calls after this process caches credentials with +//! `sudo -S -v`. The sudo password is never written to disk, never placed in +//! argv/env, and is redacted from any captured output before it reaches the UI. + +use std::{ + io::{ErrorKind, Write}, + path::Path, + process::{Command, Output, Stdio}, + sync::mpsc::Sender, +}; + +use crate::catalog::{Task, display_command}; +use crate::system::{command_exists, detect_package_manager}; +use crate::validate::{is_safe_script_name, redact_secret, validate_package_name, zeroize_string}; + +#[derive(Debug)] +pub enum RunnerMessage { + Log(String), + Done, +} + +pub fn run_tasks(tasks: Vec, mut password: String, tx: Sender) { + if let Err(error) = cache_sudo_credentials(&password) { + let _ = tx.send(RunnerMessage::Log(redact_secret(&error, &password))); + zeroize_string(&mut password); + let _ = tx.send(RunnerMessage::Done); + return; + } + + if tasks.iter().any(|task| task_needs_flatpak(&task.command)) + && let Err(error) = ensure_flatpak_package(&password, &tx) + { + let _ = tx.send(RunnerMessage::Log(redact_secret(&error, &password))); + } + + for task in tasks { + let _ = tx.send(RunnerMessage::Log(format!( + "[INFO] Starting: {}", + task.description + ))); + + if let Err(error) = validate_task_command(&task.command) { + let _ = tx.send(RunnerMessage::Log(format!("[ERROR] {error}"))); + continue; + } + + let _ = tx.send(RunnerMessage::Log(format!( + "Command: {}", + display_command(&task.command) + ))); + + let Some((program, args)) = task.command.split_first() else { + let _ = tx.send(RunnerMessage::Log("[ERROR] Empty command.".to_owned())); + continue; + }; + + let output = spawn_captured(program, args); + + match output { + Ok(output) => { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + for line in stdout.lines().chain(stderr.lines()) { + let _ = tx.send(RunnerMessage::Log(redact_secret(line, &password))); + } + if output.status.success() { + let _ = tx.send(RunnerMessage::Log(format!( + "[SUCCESS] {} completed successfully.", + task.description + ))); + } else { + let code = output + .status + .code() + .map_or_else(|| "unknown".to_owned(), |code| code.to_string()); + let _ = tx.send(RunnerMessage::Log(format!( + "[ERROR] {} failed with return code {code}.", + task.description + ))); + } + } + Err(error) => { + let _ = tx.send(RunnerMessage::Log(redact_secret( + &format!( + "[EXCEPTION] Failed to run {}: {}", + task.description, + io_error_message(program, &error) + ), + &password, + ))); + } + } + } + + drop_sudo_credentials(); + zeroize_string(&mut password); + let _ = tx.send(RunnerMessage::Done); +} + +fn spawn_captured(program: &str, args: &[String]) -> Result { + Command::new(program) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .and_then(|child| child.wait_with_output()) +} + +fn io_error_message(program: &str, error: &std::io::Error) -> String { + if error.kind() == ErrorKind::NotFound { + format!("{program} was not found on PATH.") + } else { + error.to_string() + } +} + +pub fn task_needs_flatpak(command: &[String]) -> bool { + let Some((_, args)) = command.split_first() else { + return false; + }; + let Some(script) = args.first() else { + return false; + }; + let name = Path::new(script) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default(); + name == "main.sh" + && args + .windows(2) + .any(|pair| pair[0] == "--flatpak" && crate::validate::is_flatpak_id(&pair[1])) +} + +pub fn flatpak_package_install_command(package_manager: &str) -> Result, String> { + validate_package_name("flatpak").map_err(|error| error.to_string())?; + + let mut command = vec!["sudo".to_owned(), "-S".to_owned()]; + match package_manager { + "pacman" => command.extend([ + "pacman".to_owned(), + "-S".to_owned(), + "--needed".to_owned(), + "--noconfirm".to_owned(), + "--".to_owned(), + "flatpak".to_owned(), + ]), + "apt-get" | "apt" => { + if command_exists("nala") { + command.extend([ + "nala".to_owned(), + "install".to_owned(), + "-y".to_owned(), + "flatpak".to_owned(), + ]); + } else { + command.extend([ + "apt-get".to_owned(), + "install".to_owned(), + "-y".to_owned(), + "--".to_owned(), + "flatpak".to_owned(), + ]); + } + } + "dnf" => command.extend([ + "dnf".to_owned(), + "install".to_owned(), + "-y".to_owned(), + "--".to_owned(), + "flatpak".to_owned(), + ]), + other => { + return Err(format!( + "Unsupported package manager for Flatpak bootstrap: {other}" + )); + } + } + Ok(command) +} + +fn ensure_flatpak_package(password: &str, tx: &Sender) -> Result<(), String> { + if command_exists("flatpak") { + let _ = tx.send(RunnerMessage::Log( + "[INFO] Flatpak is already installed.".to_owned(), + )); + return Ok(()); + } + + let _ = tx.send(RunnerMessage::Log( + "[INFO] Flatpak is not installed. Installing the flatpak package...".to_owned(), + )); + + let package_manager = detect_package_manager(); + let command = flatpak_package_install_command(&package_manager)?; + let _ = tx.send(RunnerMessage::Log(format!( + "Command: {}", + display_command(&command) + ))); + + let output = run_sudo_command(&command, password)?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + for line in stdout.lines().chain(stderr.lines()) { + let _ = tx.send(RunnerMessage::Log(redact_secret(line, password))); + } + + if !output.status.success() { + return Err("[ERROR] Failed to install the flatpak package.".to_owned()); + } + if !command_exists("flatpak") { + return Err( + "[ERROR] The flatpak command is still missing after package install.".to_owned(), + ); + } + + let _ = tx.send(RunnerMessage::Log( + "[SUCCESS] Flatpak package installed.".to_owned(), + )); + Ok(()) +} + +fn run_sudo_command(command: &[String], password: &str) -> Result { + let Some((program, args)) = command.split_first() else { + return Err("[ERROR] Empty Flatpak bootstrap command.".to_owned()); + }; + if program != "sudo" || !args.first().is_some_and(|arg| arg == "-S") { + return Err("[ERROR] Flatpak bootstrap must use sudo -S.".to_owned()); + } + + let mut child = match Command::new(program) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + { + Ok(child) => child, + Err(error) if error.kind() == ErrorKind::NotFound => { + return Err(format!("[ERROR] {program} was not found on PATH.")); + } + Err(error) => { + return Err(format!("[ERROR] Failed to start {program}: {error}")); + } + }; + + if let Some(mut stdin) = child.stdin.take() { + stdin + .write_all(password.as_bytes()) + .and_then(|()| stdin.write_all(b"\n")) + .map_err(|_| "[ERROR] Failed to submit sudo credentials.".to_owned())?; + } + + child + .wait_with_output() + .map_err(|error| format!("[ERROR] {}", io_error_message(program, &error))) +} + +fn validate_task_command(command: &[String]) -> Result<(), String> { + let Some((program, args)) = command.split_first() else { + return Err("Empty command.".to_owned()); + }; + if program != "bash" { + return Err("Only bash helper scripts are allowed.".to_owned()); + } + let Some(script) = args.first() else { + return Err("Missing helper script path.".to_owned()); + }; + let name = std::path::Path::new(script) + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| "Invalid helper script path.".to_owned())?; + if !is_safe_script_name(name) { + return Err(format!("Helper script {name} is not allowed.")); + } + if args.len() > 1 && name != "main.sh" { + return Err("Admin helper scripts do not accept extra arguments.".to_owned()); + } + Ok(()) +} + +fn cache_sudo_credentials(password: &str) -> Result<(), String> { + if password.is_empty() { + return Err("[ERROR] A sudo password is required.".to_owned()); + } + + let mut child = Command::new("sudo") + .args(["-S", "-v"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|_| "[ERROR] Failed to start sudo.".to_owned())?; + + if let Some(mut stdin) = child.stdin.take() { + stdin + .write_all(password.as_bytes()) + .map_err(|_| "[ERROR] Failed to submit sudo credentials.".to_owned())?; + stdin + .write_all(b"\n") + .map_err(|_| "[ERROR] Failed to submit sudo credentials.".to_owned())?; + } + + let output = child + .wait_with_output() + .map_err(|_| "[ERROR] Failed to wait for sudo.".to_owned())?; + + if output.status.success() { + Ok(()) + } else { + Err("[ERROR] sudo authentication failed.".to_owned()) + } +} + +fn drop_sudo_credentials() { + let _ = Command::new("sudo") + .arg("-k") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_task_command_accepts_main_sh_argv() { + assert!( + validate_task_command(&[ + "bash".into(), + "/opt/toolbox/main.sh".into(), + "--label".into(), + "Firefox".into(), + "--package".into(), + "firefox".into(), + "install".into(), + ]) + .is_ok() + ); + } + + #[test] + fn validate_task_command_accepts_admin_script_without_args() { + assert!( + validate_task_command(&["bash".into(), "/opt/toolbox/update-system.sh".into()]).is_ok() + ); + } + + #[test] + fn validate_task_command_rejects_shell_or_unknown_binaries() { + assert!(validate_task_command(&["sh".into(), "-c".into(), "id".into()]).is_err()); + assert!(validate_task_command(&["sudo".into(), "pacman".into(), "-Syu".into()]).is_err()); + assert!(validate_task_command(&["bash".into(), "/tmp/evil.sh".into()]).is_err()); + assert!( + validate_task_command(&[ + "bash".into(), + "/opt/toolbox/update-system.sh".into(), + "extra".into() + ]) + .is_err() + ); + } + + #[test] + fn cache_sudo_requires_password() { + assert!(cache_sudo_credentials("").is_err()); + } + + #[test] + fn task_needs_flatpak_only_for_validated_flatpak_argv() { + assert!(task_needs_flatpak(&[ + "bash".into(), + "/opt/toolbox/main.sh".into(), + "--label".into(), + "Brave Browser".into(), + "--flatpak".into(), + "com.brave.Browser".into(), + "install".into(), + ])); + assert!(!task_needs_flatpak(&[ + "bash".into(), + "/opt/toolbox/main.sh".into(), + "--label".into(), + "Firefox".into(), + "--package".into(), + "firefox".into(), + "install".into(), + ])); + assert!(!task_needs_flatpak(&[ + "bash".into(), + "/opt/toolbox/update-system.sh".into() + ])); + assert!(!task_needs_flatpak(&[ + "bash".into(), + "/opt/toolbox/main.sh".into(), + "--flatpak".into(), + "not a valid id".into(), + "install".into(), + ])); + } + + #[test] + fn flatpak_bootstrap_argv_uses_sudo_s_and_validated_package() { + let pacman = flatpak_package_install_command("pacman").unwrap(); + assert_eq!( + pacman, + [ + "sudo", + "-S", + "pacman", + "-S", + "--needed", + "--noconfirm", + "--", + "flatpak" + ] + ); + + let apt = flatpak_package_install_command("apt-get").unwrap(); + assert_eq!(apt[0], "sudo"); + assert_eq!(apt[1], "-S"); + assert!(apt.contains(&"flatpak".to_owned())); + assert!(apt.contains(&"apt-get".to_owned()) || apt.contains(&"nala".to_owned())); + + let dnf = flatpak_package_install_command("dnf").unwrap(); + assert_eq!(dnf, ["sudo", "-S", "dnf", "install", "-y", "--", "flatpak"]); + + assert!(flatpak_package_install_command("unknown").is_err()); + assert!(flatpak_package_install_command("pacman;id").is_err()); + } + + #[test] + fn missing_flatpak_binary_is_enoent_not_a_hang() { + let error = Command::new("flatpak") + .env("PATH", "/var/empty-toolbox-no-bin") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .expect_err("empty PATH must not resolve flatpak"); + assert_eq!(error.kind(), ErrorKind::NotFound); + assert!(io_error_message("flatpak", &error).contains("was not found on PATH")); + } + + #[test] + fn run_sudo_command_rejects_non_sudo_s() { + assert!(run_sudo_command(&["pacman".into(), "-S".into(), "flatpak".into()], "x").is_err()); + assert!(run_sudo_command(&["sudo".into(), "pacman".into(), "-S".into()], "x").is_err()); + } +} diff --git a/src/system.rs b/src/system.rs new file mode 100644 index 0000000..3f4ce3e --- /dev/null +++ b/src/system.rs @@ -0,0 +1,168 @@ +//! Local system inspection helpers. These never take user-controlled command +//! strings and never invoke a shell. + +use std::{env, ffi::OsStr, fs, path::Path, process::Command}; + +pub fn distro_name() -> String { + let Ok(contents) = fs::read_to_string("/etc/os-release") else { + return "Linux".to_owned(); + }; + + let mut fallback = None; + for line in contents.lines() { + if let Some(value) = line.strip_prefix("PRETTY_NAME=") { + return trim_release_value(value); + } + if let Some(value) = line.strip_prefix("NAME=") { + fallback = Some(trim_release_value(value)); + } + } + fallback.unwrap_or_else(|| "Linux".to_owned()) +} + +fn trim_release_value(value: &str) -> String { + value.trim().trim_matches('"').to_owned() +} + +pub fn detect_package_manager() -> String { + for manager in ["apt-get", "pacman", "dnf"] { + if command_exists(manager) { + return manager.to_owned(); + } + } + "unknown".to_owned() +} + +pub fn command_exists(name: &str) -> bool { + match env::var_os("PATH") { + Some(path) => command_exists_on_path(name, &path), + None => { + !name.is_empty() + && !name.contains('/') + && !name.contains('\0') + && Path::new(name).is_file() + } + } +} + +pub fn command_exists_on_path(name: &str, path_value: impl AsRef) -> bool { + if name.is_empty() || name.contains('/') || name.contains('\0') { + return false; + } + + env::split_paths(path_value.as_ref()).any(|dir| dir.join(name).is_file()) +} + +pub fn command_output(program: &str, args: &[&str]) -> String { + if program.is_empty() || program.contains('/') || program.contains('\0') { + return "Unknown".to_owned(); + } + + Command::new(program) + .args(args) + .output() + .ok() + .and_then(|output| String::from_utf8(output.stdout).ok()) + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "Unknown".to_owned()) +} + +pub fn env_or_unknown(keys: &[&str]) -> String { + for key in keys { + if let Ok(value) = env::var(key) { + let trimmed = value.trim(); + if !trimmed.is_empty() { + return trimmed.to_owned(); + } + } + } + "Unknown".to_owned() +} + +pub fn uptime() -> String { + let Ok(contents) = fs::read_to_string("/proc/uptime") else { + return "Unknown".to_owned(); + }; + let Some(first) = contents.split_whitespace().next() else { + return "Unknown".to_owned(); + }; + let Ok(seconds) = first.parse::() else { + return "Unknown".to_owned(); + }; + + let total = seconds as u64; + let days = total / 86_400; + let hours = (total % 86_400) / 3_600; + let minutes = (total % 3_600) / 60; + + match (days, hours) { + (0, 0) => format!("{minutes}m"), + (0, _) => format!("{hours}h {minutes}m"), + _ => format!("{days}d {hours}h {minutes}m"), + } +} + +pub fn strip_ansi(input: &str) -> String { + let mut output = String::with_capacity(input.len()); + let mut chars = input.chars().peekable(); + + while let Some(ch) = chars.next() { + if ch == '\u{1b}' { + for next in chars.by_ref() { + if next.is_ascii_alphabetic() { + break; + } + } + } else { + output.push(ch); + } + } + + output +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn command_exists_rejects_path_payloads() { + assert!(!command_exists("")); + assert!(!command_exists("/bin/sh")); + assert!(!command_exists("sh\0id")); + } + + #[test] + fn command_exists_finds_sh() { + assert!(command_exists("sh")); + } + + #[test] + fn command_exists_is_false_for_flatpak_on_empty_path() { + assert!(!command_exists_on_path( + "flatpak", + "/var/empty-toolbox-no-bin" + )); + assert!(!command_exists_on_path("flatpak", "")); + } + + #[test] + fn command_output_does_not_run_shell_strings() { + assert_eq!(command_output("sh;id", &[]), "Unknown"); + assert_eq!(command_output("/bin/echo", &["hi"]), "Unknown"); + } + + #[test] + fn strip_ansi_removes_escape_sequences() { + assert_eq!( + strip_ansi("\u{1b}[0;32mhello\u{1b}[0m world"), + "hello world" + ); + } + + #[test] + fn trim_release_value_strips_quotes() { + assert_eq!(trim_release_value("\"Arch Linux\""), "Arch Linux"); + } +} diff --git a/src/validate.rs b/src/validate.rs new file mode 100644 index 0000000..b4d8f58 --- /dev/null +++ b/src/validate.rs @@ -0,0 +1,389 @@ +//! Identifier and argument validation for privileged helper commands. +//! +//! These checks are defense in depth: the GUI already builds argv arrays +//! (no shell interpolation), and the helper scripts quote expansions. Rejecting +//! unexpected characters keeps package/flatpak identifiers from being treated +//! as flags or other payloads before they reach pacman/apt/dnf/flatpak. + +const MAX_PACKAGE_LEN: usize = 128; +const MAX_FLATPAK_LEN: usize = 255; +const MAX_LABEL_LEN: usize = 80; +const MAX_NOTES_LEN: usize = 240; +const MAX_CATEGORY_LEN: usize = 64; + +/// Helper scripts the GUI is allowed to invoke. Basename only. +pub const ALLOWED_HELPER_SCRIPTS: &[&str] = &[ + "main.sh", + "enable-bluetooth.sh", + "disable-bluetooth.sh", + "install-tlp.sh", + "install-powertop.sh", + "update-system.sh", + "install-nala.sh", + "install-stacer.sh", + "install-swapfix.sh", + "install-fastfetch.sh", +]; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ValidationError { + pub field: &'static str, + pub message: String, +} + +impl std::fmt::Display for ValidationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}: {}", self.field, self.message) + } +} + +impl std::error::Error for ValidationError {} + +fn err(field: &'static str, message: impl Into) -> ValidationError { + ValidationError { + field, + message: message.into(), + } +} + +/// Native package names accepted by apt/dnf/pacman. +/// +/// First character must be alphanumeric so the value cannot be parsed as a +/// leading flag (`-Syu`, `--config=...`). +pub fn is_package_name(value: &str) -> bool { + if value.is_empty() || value.len() > MAX_PACKAGE_LEN { + return false; + } + let mut chars = value.chars(); + let Some(first) = chars.next() else { + return false; + }; + if !first.is_ascii_alphanumeric() { + return false; + } + chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '+' | '-')) +} + +/// Flatpak application IDs in reverse-DNS form (at least two segments). +pub fn is_flatpak_id(value: &str) -> bool { + if value.is_empty() || value.len() > MAX_FLATPAK_LEN || !value.contains('.') { + return false; + } + value.split('.').all(|segment| { + !segment.is_empty() + && segment.len() <= 63 + && segment + .chars() + .next() + .is_some_and(|c| c.is_ascii_alphanumeric() || c == '_') + && segment + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-')) + }) +} + +/// Executable basename used only for icon lookup / catalog metadata. +pub fn is_exec_name(value: &str) -> bool { + is_package_name(value) +} + +pub fn is_action(value: &str) -> bool { + matches!(value, "install" | "remove") +} + +fn is_safe_display_text(value: &str, max_len: usize) -> bool { + !value.is_empty() + && value.len() <= max_len + && !value.starts_with('-') + && !value.contains("..") + && value.chars().all(|c| c.is_ascii_graphic() || c == ' ') + && !value.chars().any(|c| c.is_ascii_control()) +} + +pub fn is_label(value: &str) -> bool { + is_safe_display_text(value, MAX_LABEL_LEN) +} + +pub fn is_category(value: &str) -> bool { + is_safe_display_text(value, MAX_CATEGORY_LEN) +} + +pub fn is_notes(value: &str) -> bool { + value.is_empty() + || (value.len() <= MAX_NOTES_LEN + && value + .chars() + .all(|c| (c.is_ascii_graphic() || c == ' ') && !c.is_ascii_control())) +} + +pub fn is_safe_script_name(name: &str) -> bool { + ALLOWED_HELPER_SCRIPTS.contains(&name) + && !name.contains('/') + && !name.contains('\\') + && !name.contains('\0') + && !name.contains("..") +} + +pub fn validate_package_name(value: &str) -> Result<(), ValidationError> { + if is_package_name(value) { + Ok(()) + } else { + Err(err( + "package", + "must be a native package identifier (letters, digits, ._+-)", + )) + } +} + +pub fn validate_flatpak_id(value: &str) -> Result<(), ValidationError> { + if is_flatpak_id(value) { + Ok(()) + } else { + Err(err( + "flatpak", + "must be a reverse-DNS Flatpak application ID", + )) + } +} + +pub fn validate_exec_name(value: &str) -> Result<(), ValidationError> { + if is_exec_name(value) { + Ok(()) + } else { + Err(err( + "exec", + "must be a simple executable name (letters, digits, ._+-)", + )) + } +} + +pub fn validate_action(value: &str) -> Result<(), ValidationError> { + if is_action(value) { + Ok(()) + } else { + Err(err("action", "must be install or remove")) + } +} + +pub fn validate_label(value: &str) -> Result<(), ValidationError> { + if is_label(value) { + Ok(()) + } else { + Err(err("label", "contains unsupported characters")) + } +} + +pub fn validate_category(value: &str) -> Result<(), ValidationError> { + if is_category(value) { + Ok(()) + } else { + Err(err("category", "contains unsupported characters")) + } +} + +pub fn validate_notes(value: &str) -> Result<(), ValidationError> { + if is_notes(value) { + Ok(()) + } else { + Err(err("notes", "contains unsupported characters")) + } +} + +pub fn validate_script_name(name: &str) -> Result<(), ValidationError> { + if is_safe_script_name(name) { + Ok(()) + } else { + Err(err("script", "is not an allowed helper script")) + } +} + +/// Replace every occurrence of a secret in `text` so it cannot appear in logs. +pub fn redact_secret(text: &str, secret: &str) -> String { + if secret.is_empty() { + return text.to_owned(); + } + text.replace(secret, "[redacted]") +} + +/// Overwrite a String's contents before dropping it. +pub fn zeroize_string(value: &mut String) { + // fill() writes NULs through the existing allocation, then clear() sets + // len=0 without shrinking, so the secret is not left as readable UTF-8. + let len = value.len(); + value.replace_range(.., &"\0".repeat(len)); + value.clear(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_real_catalog_packages() { + for name in [ + "firefox", + "thunderbird", + "steam", + "audacity", + "mpv", + "obs-studio", + "vlc", + "libreoffice", + "gparted", + "htop", + ] { + assert!(is_package_name(name), "{name}"); + } + } + + #[test] + fn accepts_real_catalog_flatpak_ids() { + for id in [ + "com.brave.Browser", + "com.google.Chrome", + "com.microsoft.Edge", + "com.opera.Opera", + "com.vivaldi.Vivaldi", + "app.zen_browser.zen", + "com.discordapp.Discord", + "org.signal.Signal", + "com.slack.Slack", + "com.usebottles.bottles", + "org.gnome.Boxes", + "com.visualstudio.code", + "com.jetbrains.PyCharm-Community", + "net.lutris.Lutris", + "net.davidotek.pupgui2", + "com.valvesoftware.Steam", + "org.gimp.GIMP", + "com.obsproject.Studio", + "org.onlyoffice.desktopeditors", + "org.localsend.localsend_app", + ] { + assert!(is_flatpak_id(id), "{id}"); + } + } + + #[test] + fn rejects_package_flag_and_metacharacter_payloads() { + for name in [ + "", + "-Syu", + "--noconfirm", + "--config=/tmp/x", + "pkg;id", + "pkg id", + "pkg$(id)", + "pkg`id`", + "pkg|id", + "pkg&&id", + "../pkg", + "pkg/../other", + "pkg\nextra", + ] { + assert!(!is_package_name(name), "{name:?}"); + } + } + + #[test] + fn rejects_invalid_flatpak_ids() { + for id in [ + "", + "nodot", + "-com.evil.App", + "com.evil;App", + "com.evil App", + "com..evil", + ".com.evil", + "com.evil.", + ] { + assert!(!is_flatpak_id(id), "{id:?}"); + } + } + + #[test] + fn action_is_install_or_remove_only() { + assert!(is_action("install")); + assert!(is_action("remove")); + assert!(!is_action("update")); + assert!(!is_action("install; rm -rf /")); + } + + #[test] + fn helper_script_allowlist_rejects_paths() { + assert!(is_safe_script_name("main.sh")); + assert!(is_safe_script_name("update-system.sh")); + assert!(!is_safe_script_name("../main.sh")); + assert!(!is_safe_script_name("/etc/passwd")); + assert!(!is_safe_script_name("main.sh;id")); + assert!(!is_safe_script_name("not-a-script.sh")); + assert!(!is_safe_script_name("VMware Player Fix/vmware-fix.sh")); + } + + #[test] + fn redact_secret_removes_all_occurrences() { + assert_eq!( + redact_secret("auth failed for hunter2 in hunter2", "hunter2"), + "auth failed for [redacted] in [redacted]" + ); + assert_eq!(redact_secret("nothing to hide", ""), "nothing to hide"); + assert_eq!(redact_secret("no match", "secret"), "no match"); + } + + #[test] + fn zeroize_string_clears_contents() { + let mut secret = String::from("super-secret"); + zeroize_string(&mut secret); + assert!(secret.is_empty()); + } + + #[test] + fn labels_and_notes_reject_control_characters() { + assert!(is_label("Brave Browser")); + assert!(is_label("nala (rank mirrors) - Debian only")); + assert!(!is_label("bad\nlabel")); + assert!(!is_label("-sneaky")); + assert!(is_notes("Native package")); + assert!(is_notes("")); + assert!(!is_notes("line1\nline2")); + } + + #[test] + fn bash_package_and_flatpak_validators_agree_with_rust() { + let lib = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("toolbox-lib.sh"); + assert!(lib.is_file()); + + let cases = [ + ("is_valid_package_name", "firefox", true), + ("is_valid_package_name", "obs-studio", true), + ("is_valid_package_name", "-Syu", false), + ("is_valid_package_name", "pkg;id", false), + ("is_valid_flatpak_id", "com.brave.Browser", true), + ( + "is_valid_flatpak_id", + "com.jetbrains.PyCharm-Community", + true, + ), + ("is_valid_flatpak_id", "nodot", false), + ("is_valid_flatpak_id", "com.evil;App", false), + ("is_valid_action", "install", true), + ("is_valid_action", "upgrade", false), + ]; + + for (func, value, expected) in cases { + let status = std::process::Command::new("bash") + .arg("-c") + .arg(format!("source \"$1\" && {func} \"$2\"",)) + .arg("validator") + .arg(&lib) + .arg(value) + .status() + .expect("run bash validator"); + assert_eq!( + status.success(), + expected, + "{func}({value:?}) expected {expected}" + ); + } + } +} diff --git a/toolbox-lib.sh b/toolbox-lib.sh new file mode 100644 index 0000000..3af5656 --- /dev/null +++ b/toolbox-lib.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Shared validation helpers for Toolbox scripts. Source this file; do not execute it. + +is_valid_package_name() { + local value=${1:-} + [[ "$value" =~ ^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$ ]] +} + +is_valid_flatpak_id() { + local value=${1:-} + [[ ${#value} -le 255 ]] || return 1 + [[ "$value" =~ ^[A-Za-z0-9_][A-Za-z0-9_-]*(\.[A-Za-z0-9_][A-Za-z0-9_-]*)+$ ]] +} + +is_valid_exec_name() { + is_valid_package_name "${1:-}" +} + +is_valid_label() { + local value=${1:-} + [[ -n "$value" && ${#value} -le 80 ]] || return 1 + [[ "$value" != -* && "$value" != *..* ]] || return 1 + [[ "$value" =~ ^[[:alnum:]][[:alnum:][:space:]._+()-]*$ ]] +} + +is_valid_action() { + [[ "${1:-}" == "install" || "${1:-}" == "remove" ]] +} + +require_package_name() { + if ! is_valid_package_name "${1:-}"; then + echo "Invalid package name." >&2 + exit 1 + fi +} + +require_flatpak_id() { + if ! is_valid_flatpak_id "${1:-}"; then + echo "Invalid Flatpak ID." >&2 + exit 1 + fi +} + +require_exec_name() { + if ! is_valid_exec_name "${1:-}"; then + echo "Invalid executable name." >&2 + exit 1 + fi +} + +require_label() { + if ! is_valid_label "${1:-}"; then + echo "Invalid application label." >&2 + exit 1 + fi +} + +require_action() { + if ! is_valid_action "${1:-}"; then + echo "Action must be install or remove." >&2 + exit 1 + fi +} + +arch_multilib_enabled() { + [[ -r /etc/pacman.conf ]] && grep -q '^\[multilib\]' /etc/pacman.conf +} diff --git a/update-system.sh b/update-system.sh index cfce9bf..deb7590 100644 --- a/update-system.sh +++ b/update-system.sh @@ -8,7 +8,9 @@ printf '=====================================\n' update_flatpak() { if command -v flatpak >/dev/null 2>&1; then - echo "Updating Flatpak..." + echo "Updating user Flatpaks..." + flatpak update -y + echo "Updating system Flatpaks..." sudo flatpak update -y else echo "Flatpak is not installed. Skipping Flatpak update."