whyno-cli 0.2.0

Linux permission debugger
//! Cli for whyno.
//!
//! Parses args, dispatches to check or caps mode, maps results
//! To 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};
use std::path::Path;
use std::process::ExitCode;

use clap::Parser;

use cli::{parse_cap_name, parse_operation, parse_subject, 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, false if denied.
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),
    }
}

/// Prints JSON schema for `--json` output format.
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(())
}

/// Runs a caps subcommand (install/uninstall/check).
fn run_caps(action: CapsAction) -> Result<(), WhynoError> {
    match action {
        CapsAction::Install => caps::caps_install(),
        CapsAction::Uninstall => caps::caps_uninstall(),
        CapsAction::Check => caps::caps_check(),
    }
}

/// Runs the check pipeline.
///
/// Resolves subject identity, gathers filesystem state, runs all
/// Five check layers, generates fix plan, renders output.
/// Returns true if operation is 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 = parse_operation(operation_str)?;

    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);
    let plan = whyno_core::fix::generate_fixes(&report, &state);

    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())
}

/// Extracts subject, operation, path from positional args.
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::InvalidSubject("missing path argument".into()))?;
    Ok((subject, operation, path))
}

/// Applies `--with-cap` overrides to the resolved subject.
///
/// If any cap names are given, ORs their bitmasks and sets
/// `capabilities` to `Probe::Known`, overriding any gathered value.
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)
}

/// Picks output mode from `--json` / `--explain` flags.
#[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
    }
}

/// Runs `faccessat2` cross-check when enabled.
///
/// In debug builds, always runs if subject matches calling user.
/// In release builds, only runs when `--self-test` is passed.
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 when active MAC systems are not enabled via feature flags.
///
/// `SELinux` and `AppArmor` can deny independently of DAC/ACL.
/// Detected via sysfs; policy analysis requires --features selinux/apparmor.
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");
    }
}