whyno-cli 0.5.0

Linux permission debugger
//! `[PASS]`/`[FAIL]`/`[SKIP]` checklist with ANSI color. Respects `NO_COLOR` and `--no-color`.

use std::io::Write;

use owo_colors::{OwoColorize, Stream};

use whyno_core::checks::{CheckReport, CoreLayer, LayerResult, MacLayer};
use whyno_core::fix::{commands, scoring, Fix, FixPlan};
use whyno_core::state::{Probe, SystemState};

use super::layer_name;
use crate::error::OutputError;

pub fn render(
    report: &CheckReport,
    plan: &FixPlan,
    state: &SystemState,
    writer: &mut dyn Write,
) -> Result<(), OutputError> {
    write_header(state, writer)?;
    writeln!(writer)?;

    for (layer, result) in &report.core_results {
        write_layer_line(layer, result, writer)?;
        write_fixes_for_layer(layer, plan, writer)?;
    }
    for (layer, result) in &report.mac_results {
        write_mac_layer_line(*layer, result, writer)?;
    }

    write_warnings(&plan.warnings, writer)?;
    Ok(())
}

fn write_header(state: &SystemState, writer: &mut dyn Write) -> Result<(), OutputError> {
    let s = &state.subject;
    let groups = format_groups(&s.groups);
    let caps_suffix = match s.capabilities {
        Probe::Known(bitmask) => format!(", caps={bitmask:#018x}"),
        _ => String::new(),
    };
    let target_path = state.walk.last().map(|c| c.path.display().to_string());
    let target = target_path.as_deref().unwrap_or("/");
    let op = super::layer_name::operation_name(state.operation);

    writeln!(
        writer,
        "  Subject: uid={}, gid={}, groups=[{}]{}",
        s.uid, s.gid, groups, caps_suffix
    )?;
    writeln!(writer, "  Operation: {op}")?;
    writeln!(writer, "  Target: {target}")?;
    Ok(())
}

fn format_groups(groups: &[u32]) -> String {
    groups
        .iter()
        .map(ToString::to_string)
        .collect::<Vec<_>>()
        .join(", ")
}

fn write_layer_line(
    layer: CoreLayer,
    result: &LayerResult,
    writer: &mut dyn Write,
) -> Result<(), OutputError> {
    let name = layer_name::padded(layer);
    write_result_line(name, result, writer)
}

fn write_mac_layer_line(
    layer: MacLayer,
    result: &LayerResult,
    writer: &mut dyn Write,
) -> Result<(), OutputError> {
    let name = layer_name::padded_mac(layer);
    write_result_line(name, result, writer)
}

fn write_result_line(
    name: &str,
    result: &LayerResult,
    writer: &mut dyn Write,
) -> Result<(), OutputError> {
    match result {
        LayerResult::Pass { detail, warnings } => {
            let tag = "[PASS]"
                .if_supports_color(Stream::Stdout, |s| s.green())
                .to_string();
            writeln!(writer, "  {tag} {name} -- {detail}")?;
            for warning in warnings {
                let prefix = "\u{26a0}"
                    .if_supports_color(Stream::Stdout, |s| s.yellow())
                    .to_string();
                writeln!(writer, "         {prefix} {warning}")?;
            }
        }
        LayerResult::Fail { detail, .. } => {
            let tag = "[FAIL]"
                .if_supports_color(Stream::Stdout, |s| s.red())
                .to_string();
            writeln!(writer, "  {tag} {name} -- {detail}")?;
        }
        LayerResult::Degraded { reason } => {
            let tag = "[SKIP]"
                .if_supports_color(Stream::Stdout, |s| s.yellow())
                .to_string();
            writeln!(writer, "  {tag} {name} -- {reason}")?;
        }
        _ => {
            writeln!(writer, "  [????] {name} -- unknown result")?;
        }
    }
    Ok(())
}

fn write_fixes_for_layer(
    layer: CoreLayer,
    plan: &FixPlan,
    writer: &mut dyn Write,
) -> Result<(), OutputError> {
    let fixes: Vec<&Fix> = plan.fixes.iter().filter(|f| f.layer == layer).collect();
    for fix in fixes {
        write_fix_line(fix, writer)?;
    }
    Ok(())
}

fn write_fix_line(fix: &Fix, writer: &mut dyn Write) -> Result<(), OutputError> {
    let cmd = commands::render_command(&fix.action);
    let warning = if scoring::needs_warning(fix.impact) {
        " (!)"
    } else {
        ""
    };
    writeln!(
        writer,
        "         Fix: {cmd}  [impact: {}/6]{warning}",
        fix.impact
    )?;
    Ok(())
}

fn write_warnings(warnings: &[String], writer: &mut dyn Write) -> Result<(), OutputError> {
    if warnings.is_empty() {
        return Ok(());
    }
    writeln!(writer)?;
    for warning in warnings {
        writeln!(writer, "  Warning: {warning}")?;
    }
    Ok(())
}

#[cfg(test)]
#[path = "checklist_tests.rs"]
mod tests;