whyno-cli 0.3.0

Linux permission debugger
//! Structured JSON output (schema v1).
//!
//! Dedicated output structs decouple public schema from
//! Internal types.

use std::io::Write;

use schemars::JsonSchema;
use serde::Serialize;

use whyno_core::checks::{CheckReport, LayerResult};
use whyno_core::fix::{commands, FixPlan};
use whyno_core::state::SystemState;

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

/// Renders check report as versioned JSON.
pub fn render(
    report: &CheckReport,
    plan: &FixPlan,
    state: &SystemState,
    writer: &mut dyn Write,
) -> Result<(), OutputError> {
    let output = build_output(report, plan, state);
    serde_json::to_writer_pretty(&mut *writer, &output)?;
    writeln!(writer)?;
    Ok(())
}

/// Builds complete JSON output from check results.
fn build_output(report: &CheckReport, plan: &FixPlan, state: &SystemState) -> JsonOutput {
    let target = state
        .walk
        .last()
        .map(|c| c.path.display().to_string())
        .unwrap_or_default();

    JsonOutput {
        version: 1,
        subject: build_subject(state),
        operation: format!("{:?}", state.operation).to_lowercase(),
        target,
        result: overall_result(report),
        layers: build_layers(report),
        fixes: build_fixes(plan),
        warnings: plan.warnings.clone(),
        degraded: build_degraded(report),
    }
}

/// Builds subject section from resolved identity.
fn build_subject(state: &SystemState) -> JsonSubject {
    JsonSubject {
        uid: state.subject.uid,
        gid: state.subject.gid,
        groups: state.subject.groups.clone(),
    }
}

/// Overall result string.
///
/// - `"allowed"` if no layer fails
/// - `"denied"` if any layer fails
/// - `"degraded"` if no failure but at least one layer degraded
fn overall_result(report: &CheckReport) -> String {
    if report.is_allowed() {
        let any_degraded = report.core_results.values().any(LayerResult::is_degraded);
        if any_degraded { "degraded" } else { "allowed" }.to_string()
    } else {
        "denied".to_string()
    }
}

/// Builds layers array for JSON output, core then MAC.
fn build_layers(report: &CheckReport) -> Vec<JsonLayer> {
    let mut out: Vec<JsonLayer> = report
        .core_results
        .iter()
        .map(|(l, r)| layer_to_json(layer_name::short(l).to_string(), r))
        .collect();
    for (l, r) in &report.mac_results {
        out.push(layer_to_json(layer_name::short_mac(*l).to_string(), r));
    }
    out
}

/// Converts single layer result to JSON representation.
fn layer_to_json(name: String, result: &LayerResult) -> JsonLayer {
    match result {
        LayerResult::Pass { detail, warnings } => JsonLayer {
            name,
            result: "pass".to_string(),
            detail: detail.clone(),
            warnings: warnings.clone(),
        },
        LayerResult::Fail { detail, .. } => JsonLayer {
            name,
            result: "fail".to_string(),
            detail: detail.clone(),
            warnings: vec![],
        },
        LayerResult::Degraded { reason } => JsonLayer {
            name,
            result: "degraded".to_string(),
            detail: reason.clone(),
            warnings: vec![],
        },
        _ => JsonLayer {
            name,
            result: "unknown".to_string(),
            detail: String::new(),
            warnings: vec![],
        },
    }
}

/// Builds fixes array with rendered commands.
fn build_fixes(plan: &FixPlan) -> Vec<JsonFix> {
    plan.fixes
        .iter()
        .map(|f| JsonFix {
            command: commands::render_command(&f.action),
            impact: f.impact,
            layer: layer_name::short(f.layer).to_string(),
            description: f.description.clone(),
        })
        .collect()
}

/// Builds degraded array for layers that could not be evaluated.
fn build_degraded(report: &CheckReport) -> Vec<JsonDegraded> {
    report
        .core_results
        .iter()
        .filter_map(|(l, r)| match r {
            LayerResult::Degraded { reason } => Some(JsonDegraded {
                layer: layer_name::short(l).to_string(),
                reason: reason.clone(),
            }),
            _ => None,
        })
        .collect()
}

/// Generates JSON schema for the output format.
pub fn generate_schema() -> schemars::Schema {
    schemars::schema_for!(JsonOutput)
}

/// top-level JSON output (schema v1).
#[derive(Debug, Serialize, JsonSchema)]
pub(crate) struct JsonOutput {
    version: u32,
    subject: JsonSubject,
    operation: String,
    target: String,
    result: String,
    layers: Vec<JsonLayer>,
    fixes: Vec<JsonFix>,
    warnings: Vec<String>,
    degraded: Vec<JsonDegraded>,
}

/// subject identity in JSON output.
#[derive(Debug, Serialize, JsonSchema)]
struct JsonSubject {
    uid: u32,
    gid: u32,
    groups: Vec<u32>,
}

/// single check layer result in JSON output.
#[derive(Debug, Serialize, JsonSchema)]
struct JsonLayer {
    name: String,
    result: String,
    detail: String,
    /// advisory warnings that do not block the operation.
    #[serde(skip_serializing_if = "Vec::is_empty")]
    warnings: Vec<String>,
}

/// single fix suggestion in JSON output.
#[derive(Debug, Serialize, JsonSchema)]
struct JsonFix {
    command: String,
    impact: u8,
    layer: String,
    description: String,
}

/// degraded layer entry in JSON output.
#[derive(Debug, Serialize, JsonSchema)]
struct JsonDegraded {
    layer: String,
    reason: String,
}

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