parlov-output 0.8.0

Output formatters for parlov: SARIF, terminal table, and raw JSON.
Documentation
//! Structured JSON output for single and multi-finding results.
//!
//! Defines serialization-only DTOs (`SingleFindingOutput`, `ScanOutput`) that match the parlov
//! JSON schema (see [`SCHEMA_VERSION`]). The render functions accept domain types and assemble
//! the nested JSON structure with deterministic `finding_id` fingerprints.
//!
//! Endpoint-verdict output lives in `json_endpoint`.

use parlov_core::{
    finding_id, ImpactClass, NormativeStrength, OracleResult, ScoringReason, Signal, Vector,
};
use serde::Serialize;

use crate::{ChainProvenance, ExchangeContext, ProbeContext, ReproInfo, ScanFinding};

/// Schema version stamped on every JSON output document.
pub(crate) const SCHEMA_VERSION: &str = "1.2.0";

// ── output DTOs ─────────────────────────────────────────────────────────────

/// Top-level JSON envelope for a single finding (the `existence` command).
#[derive(Serialize)]
pub(crate) struct SingleFindingOutput<'a> {
    schema_version: &'static str,
    target_url: &'a str,
    finding: FindingOutput<'a>,
}

/// Top-level JSON envelope for multiple findings (the `scan` command).
#[derive(Serialize)]
pub(crate) struct ScanOutput<'a> {
    schema_version: &'static str,
    target_url: &'a str,
    findings: Vec<FindingOutput<'a>>,
}

/// One finding within either envelope.
#[derive(Serialize)]
pub(crate) struct FindingOutput<'a> {
    finding_id: String,
    strategy: StrategyOutput<'a>,
    result: ResultOutput,
    technique: TechniqueOutput<'a>,
    matched_pattern: MatchedPatternOutput<'a>,
    evidence: EvidenceOutput<'a>,
    #[serde(skip_serializing_if = "Option::is_none")]
    repro: Option<&'a ReproInfo>,
    #[serde(skip_serializing_if = "Option::is_none")]
    probe: Option<&'a ProbeContext>,
    #[serde(skip_serializing_if = "Option::is_none")]
    exchange: Option<&'a ExchangeContext>,
    #[serde(skip_serializing_if = "Option::is_none")]
    chain_provenance: Option<&'a ChainProvenance>,
}

#[derive(Serialize)]
struct StrategyOutput<'a> {
    id: &'a str,
    name: &'a str,
    method: &'a str,
}

#[derive(Serialize)]
struct ResultOutput {
    oracle_class: String,
    verdict: String,
    confidence: u8,
    severity: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    impact_class: Option<String>,
}

#[derive(Serialize)]
struct TechniqueOutput<'a> {
    id: &'a str,
    vector: String,
    normative_strength: String,
}

#[derive(Serialize)]
struct MatchedPatternOutput<'a> {
    #[serde(skip_serializing_if = "Option::is_none")]
    label: Option<&'a str>,
    #[serde(skip_serializing_if = "Option::is_none")]
    leaks: Option<&'a str>,
    #[serde(skip_serializing_if = "Option::is_none")]
    rfc_basis: Option<&'a str>,
}

#[derive(Serialize)]
struct EvidenceOutput<'a> {
    reasons: &'a [ScoringReason],
    signals: &'a [Signal],
}

// ── public render functions ─────────────────────────────────────────────────

/// Renders a single `OracleResult` as the v1.2.0 nested JSON schema.
///
/// # Errors
///
/// Returns `Err` if `serde_json` serialization fails (cannot occur for well-formed inputs).
pub fn render_json(
    target_url: &str,
    result: &OracleResult,
    strategy_id: &str,
    strategy_name: &str,
    method: &str,
) -> Result<String, serde_json::Error> {
    let finding = build_finding(
        target_url,
        result,
        strategy_id,
        strategy_name,
        method,
        None,
        None,
        None,
        None,
    );
    let output = SingleFindingOutput {
        schema_version: SCHEMA_VERSION,
        target_url,
        finding,
    };
    serde_json::to_string_pretty(&output)
}

/// Renders a slice of scan findings as the v1.2.0 nested JSON schema.
///
/// All findings share `target_url` at the root level.
///
/// # Errors
///
/// Returns `Err` if `serde_json` serialization fails (cannot occur for well-formed inputs).
pub fn render_scan_json(
    target_url: &str,
    findings: &[ScanFinding],
) -> Result<String, serde_json::Error> {
    let items: Vec<FindingOutput> = findings
        .iter()
        .map(|f| {
            build_finding(
                target_url,
                &f.result,
                &f.strategy_id,
                &f.strategy_name,
                &f.method,
                f.repro.as_ref(),
                Some(&f.probe),
                Some(&f.exchange),
                f.chain_provenance.as_ref(),
            )
        })
        .collect();
    let output = ScanOutput {
        schema_version: SCHEMA_VERSION,
        target_url,
        findings: items,
    };
    serde_json::to_string_pretty(&output)
}

// ── builders ────────────────────────────────────────────────────────────────

#[allow(clippy::too_many_arguments)]
pub(crate) fn build_finding<'a>(
    target_url: &str,
    result: &'a OracleResult,
    strategy_id: &'a str,
    strategy_name: &'a str,
    method: &'a str,
    repro: Option<&'a ReproInfo>,
    probe: Option<&'a ProbeContext>,
    exchange: Option<&'a ExchangeContext>,
    chain_provenance: Option<&'a ChainProvenance>,
) -> FindingOutput<'a> {
    let oracle_class = result.class.to_string();
    let technique_id = result.technique_id.as_deref().unwrap_or("unknown");
    let fid = finding_id(technique_id, target_url, &oracle_class, method, strategy_id);

    FindingOutput {
        finding_id: fid,
        strategy: StrategyOutput {
            id: strategy_id,
            name: strategy_name,
            method,
        },
        result: build_result_output(result, &oracle_class),
        technique: build_technique_output(result),
        matched_pattern: build_matched_pattern(result),
        evidence: EvidenceOutput {
            reasons: &result.reasons,
            signals: &result.signals,
        },
        repro,
        probe,
        exchange,
        chain_provenance,
    }
}

fn build_result_output(result: &OracleResult, oracle_class: &str) -> ResultOutput {
    ResultOutput {
        oracle_class: oracle_class.to_owned(),
        verdict: result.verdict.to_string(),
        confidence: result.confidence,
        severity: format_severity(result.severity.as_ref()),
        impact_class: result.impact_class.map(format_impact_class),
    }
}

fn build_technique_output(result: &OracleResult) -> TechniqueOutput<'_> {
    TechniqueOutput {
        id: result.technique_id.as_deref().unwrap_or("unknown"),
        vector: result
            .vector
            .map_or_else(|| "Unknown".to_owned(), format_vector),
        normative_strength: result
            .normative_strength
            .map_or_else(|| "Unknown".to_owned(), format_normative_strength),
    }
}

fn build_matched_pattern(result: &OracleResult) -> MatchedPatternOutput<'_> {
    MatchedPatternOutput {
        label: result.label.as_deref(),
        leaks: result.leaks.as_deref(),
        rfc_basis: result.rfc_basis.as_deref(),
    }
}

// ── formatting helpers ──────────────────────────────────────────────────────

pub(crate) fn format_severity(severity: Option<&parlov_core::Severity>) -> String {
    match severity {
        Some(s) => s.to_string(),
        None => "None".to_owned(),
    }
}

fn format_impact_class(ic: ImpactClass) -> String {
    ic.to_string()
}

fn format_vector(v: Vector) -> String {
    v.to_string()
}

fn format_normative_strength(ns: NormativeStrength) -> String {
    ns.to_string()
}

pub use crate::json_endpoint::render_endpoint_verdict_json;

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