sysknife-cli 0.8.0

Approval-gated AI system administration CLI and MCP server
//! Distro-family routing guard for action names.
//!
//! Some actions in the SysKnife catalogue are distro-specific — they are only
//! meaningful on a particular package-manager family:
//!
//! - `Apt*`, `Snap*`, `Ufw*`, `Distrobox*`, `Netplan*` require a Debian-family
//!   distro (Ubuntu or Debian).
//! - rpm-ostree shaped actions (`RebaseSystem`, `AddLayeredPackage`, …) require
//!   a Fedora-family distro.
//!
//! [`check_action_distro`] returns a human-readable error when a plan step's
//! action name is incompatible with the detected distro.  It is called after
//! planning, before execution, so the user sees a clear message instead of a
//! cryptic daemon error.

use sysknife_core::distro::{DistroFamily, DistroId};

// ---------------------------------------------------------------------------
// Action families
// ---------------------------------------------------------------------------

// The canonical family lists are the single source of truth in
// `sysknife-core::action_family`; the daemon fence, this routing guard, and the
// brain prompt all reference the same constants so they cannot drift apart.
use sysknife_core::action_family::{DEBIAN_ONLY_ACTIONS, FEDORA_ONLY_ACTIONS};

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

/// Check whether `action_name` is compatible with `distro`.
///
/// Returns `Ok(())` when the action is generic (valid on all distros), or when
/// the action's required family matches `distro`'s family.
///
/// Returns `Err(String)` with a human-readable routing error when the action
/// is family-restricted and `distro` is from the wrong family.
///
/// When `distro` is `None` (detection failed), all actions are allowed — the
/// daemon will surface the real error at execution time.
pub fn check_action_distro(action_name: &str, distro: Option<&DistroId>) -> Result<(), String> {
    let distro = match distro {
        Some(d) => d,
        None => return Ok(()), // detection failed; let daemon handle it
    };

    let family = distro.family();
    if DEBIAN_ONLY_ACTIONS.contains(&action_name) && family != DistroFamily::Debian {
        return Err(format!(
            "{action_name} is only valid on Debian-family distros (apt/snap/ufw); \
             current distro is {distro} ({family_name})",
            family_name = family_label(&family),
        ));
    }

    if FEDORA_ONLY_ACTIONS.contains(&action_name) && family != DistroFamily::Fedora {
        return Err(format!(
            "{action_name} is only valid on Fedora-family distros (rpm-ostree/dnf); \
             current distro is {distro} ({family_name})",
            family_name = family_label(&family),
        ));
    }

    // Kept in step with the daemon's own fence in `validate_action_platform`,
    // which does not exempt reads either: the RBAC role is a bad proxy for "does
    // this mutate" — `AptUpdate` is Low/Observer and runs `sudo apt-get update`.
    // A client that refuses what the daemon would run is confusing; a client that
    // *permits* what the daemon refuses is worse, so both stay strict together.
    if (DEBIAN_ONLY_ACTIONS.contains(&action_name) || FEDORA_ONLY_ACTIONS.contains(&action_name))
        && !distro.is_supported()
    {
        return Err(format!(
            "{action_name} is disabled on unsupported distro {distro}; \
             see docs/distro-support.md"
        ));
    }

    Ok(())
}

fn family_label(f: &DistroFamily) -> &'static str {
    match f {
        DistroFamily::Fedora => "Fedora family",
        DistroFamily::Debian => "Debian family",
        DistroFamily::Other => "unknown family",
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use sysknife_core::distro::DistroId;

    // -----------------------------------------------------------------------
    // Fedora routing
    // -----------------------------------------------------------------------

    #[test]
    fn apt_install_on_fedora_returns_error() {
        let distro = DistroId::Fedora { version: 41 };
        let result = check_action_distro("AptInstall", Some(&distro));
        assert!(result.is_err());
        let msg = result.unwrap_err();
        assert!(
            msg.contains("Debian-family"),
            "error must mention Debian-family; got: {msg}"
        );
        assert!(
            msg.contains("Fedora 41"),
            "error must mention current distro; got: {msg}"
        );
    }

    #[test]
    fn snap_install_on_fedora_silverblue_returns_error() {
        let distro = DistroId::FedoraSilverblue { version: 41 };
        let result = check_action_distro("SnapInstall", Some(&distro));
        assert!(result.is_err());
        let msg = result.unwrap_err();
        assert!(msg.contains("Debian-family"), "got: {msg}");
        assert!(msg.contains("FedoraSilverblue 41"), "got: {msg}");
    }

    #[test]
    fn ufw_allow_on_fedora_returns_error() {
        let distro = DistroId::Fedora { version: 41 };
        let result = check_action_distro("UfwAllow", Some(&distro));
        assert!(result.is_err());
    }

    #[test]
    fn netplan_apply_on_fedora_returns_error() {
        let distro = DistroId::Fedora { version: 41 };
        let result = check_action_distro("NetplanApply", Some(&distro));
        assert!(result.is_err());
    }

    #[test]
    fn distrobox_create_on_fedora_returns_error() {
        let distro = DistroId::Fedora { version: 41 };
        let result = check_action_distro("DistroboxCreate", Some(&distro));
        assert!(result.is_err());
    }

    // -----------------------------------------------------------------------
    // Ubuntu routing
    // -----------------------------------------------------------------------

    #[test]
    fn apt_install_on_ubuntu_is_ok() {
        let distro = DistroId::Ubuntu {
            major: 24,
            minor: 4,
        };
        assert!(check_action_distro("AptInstall", Some(&distro)).is_ok());
    }

    #[test]
    fn apt_install_on_ubuntu_core_is_rejected() {
        let distro = DistroId::UbuntuCore {
            major: 24,
            minor: 4,
        };
        assert!(check_action_distro("AptInstall", Some(&distro)).is_err());
    }

    #[test]
    fn apt_install_on_unsupported_ubuntu_release_is_rejected() {
        // 18.04 is below the support floor: no longer receiving Ubuntu security
        // updates, so the routing guard still refuses it.
        let distro = DistroId::Ubuntu {
            major: 18,
            minor: 4,
        };
        assert!(check_action_distro("AptInstall", Some(&distro)).is_err());
    }

    #[test]
    fn apt_install_is_allowed_on_every_supported_ubuntu_release() {
        // The counterpart to the test above: interim releases and 20.04 are
        // supported, so routing must not treat the LTS cadence as a gate.
        for (major, minor) in [(20, 4), (22, 4), (24, 4), (25, 10), (26, 4), (26, 10)] {
            let distro = DistroId::Ubuntu { major, minor };
            assert!(
                check_action_distro("AptInstall", Some(&distro)).is_ok(),
                "AptInstall must route on Ubuntu {major}.{minor:02}"
            );
        }
    }

    #[test]
    fn snap_install_on_ubuntu_is_ok() {
        let distro = DistroId::Ubuntu {
            major: 24,
            minor: 4,
        };
        assert!(check_action_distro("SnapInstall", Some(&distro)).is_ok());
    }

    #[test]
    fn rebase_system_on_ubuntu_returns_error() {
        let distro = DistroId::Ubuntu {
            major: 24,
            minor: 4,
        };
        let result = check_action_distro("RebaseSystem", Some(&distro));
        assert!(result.is_err());
        let msg = result.unwrap_err();
        assert!(msg.contains("Fedora-family"), "got: {msg}");
        assert!(msg.contains("Ubuntu 24.04"), "got: {msg}");
    }

    #[test]
    fn add_layered_package_on_ubuntu_returns_error() {
        let distro = DistroId::Ubuntu {
            major: 24,
            minor: 4,
        };
        let result = check_action_distro("AddLayeredPackage", Some(&distro));
        assert!(result.is_err());
    }

    // -----------------------------------------------------------------------
    // UnknownDistro / None passes through
    // -----------------------------------------------------------------------

    #[test]
    fn no_distro_allows_all_actions() {
        // When detection failed, we must not block execution.
        assert!(check_action_distro("AptInstall", None).is_ok());
        assert!(check_action_distro("RebaseSystem", None).is_ok());
        assert!(check_action_distro("GetSystemState", None).is_ok());
    }

    // -----------------------------------------------------------------------
    // Generic actions are allowed everywhere
    // -----------------------------------------------------------------------

    /// Silverblue, not plain Fedora: `GetSystemState` runs `rpm-ostree status`,
    /// which a dnf-based Fedora Workstation does not have — and plain Fedora is
    /// `is_supported() == false` besides. The old fixture used `Fedora`, so it
    /// asserted an rpm-ostree action was fine on a host without rpm-ostree.
    #[test]
    fn get_system_state_allowed_on_fedora_atomic() {
        let distro = DistroId::FedoraSilverblue { version: 41 };
        assert!(check_action_distro("GetSystemState", Some(&distro)).is_ok());
    }

    /// This pair used to assert that `GetSystemState` was "generic, allowed
    /// everywhere". It is not: it runs `rpm-ostree status --json`, so on an apt
    /// host it failed with `command not found` — *after* the operator had
    /// approved it. The routing guard now refuses it up front, and `GetHostState`
    /// is what Ubuntu answers the same question with (#181).
    #[test]
    fn get_system_state_is_refused_on_ubuntu_before_approval() {
        let distro = DistroId::Ubuntu {
            major: 24,
            minor: 4,
        };
        assert!(
            check_action_distro("GetSystemState", Some(&distro)).is_err(),
            "an rpm-ostree action must not reach an apt host's approval prompt"
        );
    }

    #[test]
    fn get_host_state_is_the_ubuntu_counterpart() {
        let ubuntu = DistroId::Ubuntu {
            major: 24,
            minor: 4,
        };
        assert!(check_action_distro("GetHostState", Some(&ubuntu)).is_ok());

        let fedora = DistroId::FedoraSilverblue { version: 41 };
        assert!(
            check_action_distro("GetHostState", Some(&fedora)).is_err(),
            "the fence runs both ways: Fedora has GetSystemState"
        );
    }

    #[test]
    fn install_flatpak_allowed_on_fedora() {
        let distro = DistroId::Fedora { version: 41 };
        assert!(check_action_distro("InstallFlatpak", Some(&distro)).is_ok());
    }

    #[test]
    fn install_flatpak_allowed_on_ubuntu() {
        let distro = DistroId::Ubuntu {
            major: 24,
            minor: 4,
        };
        assert!(check_action_distro("InstallFlatpak", Some(&distro)).is_ok());
    }

    // -----------------------------------------------------------------------
    // All Debian-only actions reject Fedora
    // -----------------------------------------------------------------------

    #[test]
    fn all_debian_only_actions_rejected_on_fedora() {
        let distro = DistroId::Fedora { version: 41 };
        for action in super::DEBIAN_ONLY_ACTIONS {
            let result = check_action_distro(action, Some(&distro));
            assert!(
                result.is_err(),
                "{action} should be rejected on Fedora but was allowed"
            );
        }
    }

    #[test]
    fn all_fedora_only_actions_rejected_on_ubuntu() {
        let distro = DistroId::Ubuntu {
            major: 24,
            minor: 4,
        };
        for action in super::FEDORA_ONLY_ACTIONS {
            let result = check_action_distro(action, Some(&distro));
            assert!(
                result.is_err(),
                "{action} should be rejected on Ubuntu but was allowed"
            );
        }
    }

    // -----------------------------------------------------------------------
    // Display impl for DistroId
    // -----------------------------------------------------------------------

    #[test]
    fn distro_id_display_fedora() {
        assert_eq!(format!("{}", DistroId::Fedora { version: 41 }), "Fedora 41");
    }

    #[test]
    fn distro_id_display_fedora_silverblue() {
        assert_eq!(
            format!("{}", DistroId::FedoraSilverblue { version: 41 }),
            "FedoraSilverblue 41"
        );
    }

    #[test]
    fn distro_id_display_ubuntu() {
        assert_eq!(
            format!(
                "{}",
                DistroId::Ubuntu {
                    major: 24,
                    minor: 4
                }
            ),
            "Ubuntu 24.04"
        );
    }

    #[test]
    fn distro_id_display_debian() {
        assert_eq!(
            format!("{}", DistroId::Debian { version: Some(12) }),
            "Debian 12"
        );
    }
}