whyno-cli 0.5.0

Linux permission debugger
//! Parses args, dispatches to check or caps mode.
//!
//! Exit codes: 0 allowed, 1 denied, 2 error.

#![deny(clippy::all)]
#![warn(clippy::pedantic)]

mod caps;
mod cli;
mod crosscheck;
mod error;
mod output;

use std::io::{self, Write};
#[cfg(not(all(feature = "selinux", feature = "apparmor")))]
use std::path::Path;
use std::process::ExitCode;

use clap::Parser;

use cli::{
    build_metadata_params, parse_cap_name, parse_subject, resolve_operation, validate_flags,
    CapsAction, Cli, Commands,
};
use error::WhynoError;
use whyno_core::state::subject::ResolvedSubject;
use whyno_core::state::Probe;

fn main() -> ExitCode {
    let cli = Cli::parse();
    configure_color(&cli);

    match run(&cli) {
        Ok(allowed) => {
            if allowed {
                ExitCode::SUCCESS
            } else {
                ExitCode::from(1)
            }
        }
        Err(e) => {
            eprintln!("error: {e}");
            ExitCode::from(2)
        }
    }
}

/// Color setup from `--no-color` and `NO_COLOR` env.
fn configure_color(cli: &Cli) {
    let no_color = cli.no_color || std::env::var_os("NO_COLOR").is_some();
    if no_color {
        owo_colors::set_override(false);
    }
}

/// Dispatches to check, caps, or schema mode. Returns true if allowed.
fn run(cli: &Cli) -> Result<bool, WhynoError> {
    match cli.command {
        Some(Commands::Caps { action }) => {
            run_caps(action)?;
            Ok(true)
        }
        Some(Commands::Schema) => {
            run_schema()?;
            Ok(true)
        }
        None => run_check(cli),
    }
}

fn run_schema() -> Result<(), WhynoError> {
    let schema = output::json::generate_schema();
    let json =
        serde_json::to_string_pretty(&schema).map_err(error::OutputError::SerializationFailed)?;
    println!("{json}");
    Ok(())
}

fn run_caps(action: CapsAction) -> Result<(), WhynoError> {
    match action {
        CapsAction::Install => caps::caps_install(),
        CapsAction::Uninstall => caps::caps_uninstall(),
        CapsAction::Check => caps::caps_check(),
    }
}

/// Resolves subject, gathers state, runs all check layers, renders output. Returns true if allowed.
fn run_check(cli: &Cli) -> Result<bool, WhynoError> {
    validate_flags(cli)?;

    let (subject_str, operation_str, path) = extract_args(cli)?;
    let subject_input = parse_subject(subject_str)?;
    let operation = resolve_operation(operation_str, cli.xattr_key.as_deref())?;
    let params = build_metadata_params(cli)?;

    let mut subject = resolve_subject(&subject_input)?;
    apply_cap_overrides(&mut subject, &cli.with_cap)?;
    let state = whyno_gather::gather_state(&subject, operation, path)?;

    warn_mac_systems();

    let report = whyno_core::checks::run_checks(&state, &params);
    let plan = whyno_core::fix::generate_fixes(&report, &state, &params);

    run_cross_check_if_enabled(cli.self_test, &state, &report);

    let mode = output_mode(cli);
    let mut stdout = io::stdout().lock();
    output::render(&report, &plan, &state, mode, &mut stdout)?;
    stdout.flush().map_err(error::OutputError::WriteFailed)?;

    Ok(report.is_allowed())
}

fn extract_args(cli: &Cli) -> Result<(&str, &str, &std::path::Path), WhynoError> {
    let subject = cli
        .subject
        .as_deref()
        .ok_or_else(|| error::CliError::InvalidSubject("missing subject argument".into()))?;
    let operation = cli
        .operation
        .as_deref()
        .ok_or_else(|| error::CliError::InvalidOperation("missing operation argument".into()))?;
    let path = cli
        .path
        .as_deref()
        .ok_or_else(|| error::CliError::MissingArg("path".into()))?;
    Ok((subject, operation, path))
}

/// ORs bitmasks from `--with-cap` names, sets capabilities to `Probe::Known`.
fn apply_cap_overrides(
    subject: &mut ResolvedSubject,
    cap_names: &[String],
) -> Result<(), WhynoError> {
    if cap_names.is_empty() {
        return Ok(());
    }
    let mut bitmask: u64 = 0;
    for name in cap_names {
        bitmask |= parse_cap_name(name)?;
    }
    subject.capabilities = Probe::Known(bitmask);
    Ok(())
}

/// Resolves subject input to uid/gid/groups.
fn resolve_subject(input: &cli::SubjectInput) -> Result<ResolvedSubject, WhynoError> {
    let subject = match input {
        cli::SubjectInput::Username(name) => whyno_gather::subject::resolve_username(name)?,
        cli::SubjectInput::Uid(uid) => whyno_gather::subject::resolve_uid(*uid)?,
        cli::SubjectInput::Pid(pid) => whyno_gather::subject::resolve_pid(*pid)?,
        cli::SubjectInput::Service(name) => whyno_gather::subject::resolve_service(name)?,
    };
    Ok(subject)
}

#[must_use]
fn output_mode(cli: &Cli) -> output::OutputMode {
    if cli.json {
        output::OutputMode::Json
    } else if cli.explain {
        output::OutputMode::Explain
    } else {
        output::OutputMode::Checklist
    }
}

/// In debug builds runs automatically; release-mode requires `--self-test`.
fn run_cross_check_if_enabled(
    self_test: bool,
    state: &whyno_core::state::SystemState,
    report: &whyno_core::checks::CheckReport,
) {
    let should_run = self_test || cfg!(debug_assertions);
    if should_run {
        crosscheck::maybe_cross_check(state, report);
    }
}

/// Warns if SELinux/AppArmor are active but not compiled in.
fn warn_mac_systems() {
    #[cfg(not(feature = "selinux"))]
    if Path::new("/sys/fs/selinux").exists() {
        eprintln!(
            "Note: SELinux is active — rebuild with --features selinux to include MAC checks"
        );
    }
    #[cfg(not(feature = "apparmor"))]
    if Path::new("/sys/kernel/security/apparmor").exists() {
        eprintln!(
            "Note: AppArmor is active — rebuild with --features apparmor to include MAC checks"
        );
    }
}