5.6 KiB · text History e832e77
//! Package management abstraction for the reconciler.
//!
//! Like [`super::systemd`], the production implementation shells out to the
//! host's package manager. It auto-detects which one is present (zypper, dnf,
//! apt, or pacman) so the same manifest works across distros. Tests inject a
//! fake to assert idempotency without touching the system.

use std::io;
use std::process::Command;

/// Side-effecting package operations, injectable for testing.
pub trait PackageManager {
    /// Whether `name` is currently installed.
    fn is_installed(&self, name: &str) -> io::Result<bool>;
    /// Install `name`.
    fn install(&mut self, name: &str) -> io::Result<()>;
    /// Remove `name`.
    fn remove(&mut self, name: &str) -> io::Result<()>;
    /// Human-readable backend name, for logging.
    fn backend(&self) -> &str;
}

/// Which package manager the host uses.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Backend {
    Zypper,
    Dnf,
    Apt,
    Pacman,
}

impl Backend {
    fn as_str(self) -> &'static str {
        match self {
            Backend::Zypper => "zypper",
            Backend::Dnf => "dnf",
            Backend::Apt => "apt",
            Backend::Pacman => "pacman",
        }
    }

    /// The binary whose presence identifies this backend.
    fn probe_bin(self) -> &'static str {
        match self {
            Backend::Zypper => "zypper",
            Backend::Dnf => "dnf",
            Backend::Apt => "apt-get",
            Backend::Pacman => "pacman",
        }
    }
}

/// Production [`PackageManager`] backed by the detected system tool. If no known
/// manager is found, package operations error (surfaced per-package, so a host
/// without a supported manager fails only the package resources).
#[derive(Debug, Clone, Copy)]
pub struct SystemPackageManager {
    backend: Option<Backend>,
}

impl SystemPackageManager {
    /// Detect the host's package manager by probing `PATH` in priority order.
    pub fn detect() -> Self {
        // zypper before dnf: an openSUSE host may have both, but zypper is
        // canonical there; apt/pacman are unambiguous.
        let backend = [Backend::Zypper, Backend::Dnf, Backend::Apt, Backend::Pacman]
            .into_iter()
            .find(|b| binary_in_path(b.probe_bin()));
        Self { backend }
    }

    fn backend_or_err(&self) -> io::Result<Backend> {
        self.backend.ok_or_else(|| {
            io::Error::new(
                io::ErrorKind::Unsupported,
                "no supported package manager found (zypper/dnf/apt/pacman)",
            )
        })
    }

    /// Run a mutating command, mapping a non-zero exit into an error with stderr.
    fn run(&self, program: &str, args: &[&str]) -> io::Result<()> {
        let mut cmd = Command::new(program);
        cmd.args(args);
        // apt is interactive by default; make it quiet + non-interactive.
        if program == "apt-get" {
            cmd.env("DEBIAN_FRONTEND", "noninteractive");
        }
        let out = cmd.output()?;
        if out.status.success() {
            Ok(())
        } else {
            Err(io::Error::other(format!(
                "{program} {} exited with {}: {}",
                args.join(" "),
                out.status,
                String::from_utf8_lossy(&out.stderr).trim()
            )))
        }
    }
}

impl PackageManager for SystemPackageManager {
    fn is_installed(&self, name: &str) -> io::Result<bool> {
        let backend = self.backend_or_err()?;
        // Query commands use exit status to report installed/not-installed, so a
        // non-zero exit is "not installed", not an error.
        let (program, args): (&str, Vec<&str>) = match backend {
            // rpm works for both zypper and dnf and is faster than the manager.
            Backend::Zypper | Backend::Dnf => ("rpm", vec!["-q", name]),
            Backend::Apt => ("dpkg-query", vec!["-W", "-f=${Status}", name]),
            Backend::Pacman => ("pacman", vec!["-Q", name]),
        };
        let out = Command::new(program).args(&args).output()?;
        match backend {
            Backend::Apt => {
                // dpkg-query prints "install ok installed" for present packages.
                Ok(out.status.success()
                    && String::from_utf8_lossy(&out.stdout).contains("install ok installed"))
            }
            _ => Ok(out.status.success()),
        }
    }

    fn install(&mut self, name: &str) -> io::Result<()> {
        match self.backend_or_err()? {
            Backend::Zypper => self.run("zypper", &["--non-interactive", "install", name]),
            Backend::Dnf => self.run("dnf", &["install", "-y", name]),
            Backend::Apt => self.run("apt-get", &["install", "-y", name]),
            Backend::Pacman => self.run("pacman", &["-S", "--noconfirm", name]),
        }
    }

    fn remove(&mut self, name: &str) -> io::Result<()> {
        match self.backend_or_err()? {
            Backend::Zypper => self.run("zypper", &["--non-interactive", "remove", name]),
            Backend::Dnf => self.run("dnf", &["remove", "-y", name]),
            Backend::Apt => self.run("apt-get", &["remove", "-y", name]),
            Backend::Pacman => self.run("pacman", &["-R", "--noconfirm", name]),
        }
    }

    fn backend(&self) -> &str {
        self.backend.map(Backend::as_str).unwrap_or("none")
    }
}

/// Whether `bin` is an executable found on `PATH`.
fn binary_in_path(bin: &str) -> bool {
    let Some(path) = std::env::var_os("PATH") else {
        return false;
    };
    std::env::split_paths(&path).any(|dir| {
        let candidate = dir.join(bin);
        std::fs::metadata(&candidate)
            .map(|m| m.is_file())
            .unwrap_or(false)
    })
}