cellos-projector 0.5.0

Projection layer for CellOS — consumes JetStream CloudEvents into in-memory cell/formation state. Used by cellos-server.
Documentation
//! F6a — evidence_bundle → one-page audit doc renderer.
//!
//! Pure, deterministic Markdown projection of a single
//! `dev.cellos.events.cell.evidence_bundle.v1.emitted` CloudEvent.
//!
//! Design contract:
//! - Input: `&serde_json::Value` (a CloudEvent v1 envelope).
//! - Output: `String` — Markdown document (six sections), or a one-line
//!   Markdown error sentinel when input fails the lightest validation
//!   (wrong `type`, missing `data`). Never panics (D11).
//! - Reads ONLY `data.*` fields. The library deliberately does not pull in
//!   `cellos-core::events` typed shapes — F6a is a wire-shape consumer.
//! - Pure: same input twice yields byte-identical output.
//!
//! Expected `data` shape (best-effort; missing fields surface as
//! `unknown` rather than errors):
//! ```text
//! data: {
//!   cellId, runId, specId, specSignatureHash,
//!   lifecycle: [ { type, time }, ... ],
//!   hostSeries: { fcMetrics, cgroup, nftables, tap },
//!   guestEvents: [ { type, ... }, ... ],
//!   residueClass,
//!   attestations: { envelopeSignature, specSignatureHash },
//! }
//! ```
//!
//! Sections rendered (in order):
//! 1. Header (cellId / runId / specId / specSignatureHash)
//! 2. Lifecycle table (type | time, in input order — caller-defined)
//! 3. Host-probe series summary (fcMetrics / cgroup / nftables / tap counts)
//! 4. Guest events: total count + first ≤5 sample types
//! 5. Residue-class footer
//! 6. Signing / integrity attestations

use serde_json::Value;

/// Expected CloudEvent `type` for this consumer.
pub const EXPECTED_TYPE: &str = "dev.cellos.events.cell.evidence_bundle.v1.emitted";

/// Marker emitted when input cannot be rendered. Tests rely on it being a
/// stable, single-line Markdown sentinel (D11 — never panic).
pub const ERROR_SENTINEL_PREFIX: &str = "> **audit_doc: error**";

/// Render a one-page Markdown audit doc from an evidence_bundle CloudEvent.
///
/// Pure & deterministic. Returns a Markdown error sentinel (single line,
/// prefixed with [`ERROR_SENTINEL_PREFIX`]) instead of panicking on
/// malformed input.
pub fn render_audit_doc(event: &Value) -> String {
    // Validate envelope `type` first — wrong type is an error.
    let ty = event.get("type").and_then(|v| v.as_str()).unwrap_or("");
    if ty != EXPECTED_TYPE {
        return format!(
            "{ERROR_SENTINEL_PREFIX} expected event type `{EXPECTED_TYPE}`, got `{ty}`\n"
        );
    }

    // Missing `data` — surface as error sentinel (D11 non-panic path).
    let Some(data) = event.get("data") else {
        return format!("{ERROR_SENTINEL_PREFIX} event has no `data` payload\n");
    };

    let mut out = String::new();

    // ----- Section 1: header ------------------------------------------------
    let cell_id = str_field(data, "cellId");
    let run_id = str_field(data, "runId");
    let spec_id = str_field(data, "specId");
    let spec_signature_hash = str_field(data, "specSignatureHash");

    out.push_str("# CellOS Evidence Bundle — Audit Document\n");
    out.push('\n');
    out.push_str(&format!("- **cellId**: `{cell_id}`\n"));
    out.push_str(&format!("- **runId**: `{run_id}`\n"));
    out.push_str(&format!("- **specId**: `{spec_id}`\n"));
    out.push_str(&format!(
        "- **specSignatureHash**: `{spec_signature_hash}`\n"
    ));
    out.push('\n');

    // ----- Section 2: lifecycle table --------------------------------------
    out.push_str("## Lifecycle\n");
    out.push('\n');
    out.push_str("| # | type | time |\n");
    out.push_str("|---|------|------|\n");
    if let Some(arr) = data.get("lifecycle").and_then(|v| v.as_array()) {
        for (idx, entry) in arr.iter().enumerate() {
            let ty = entry
                .get("type")
                .and_then(|v| v.as_str())
                .unwrap_or("unknown");
            let time = entry
                .get("time")
                .and_then(|v| v.as_str())
                .unwrap_or("unknown");
            out.push_str(&format!("| {} | `{}` | `{}` |\n", idx + 1, ty, time));
        }
    }
    out.push('\n');

    // ----- Section 3: host-probe series summary -----------------------------
    out.push_str("## Host Probe Series\n");
    out.push('\n');
    let host_series = data.get("hostSeries");
    for probe in ["fcMetrics", "cgroup", "nftables", "tap"] {
        let count = host_series
            .and_then(|hs| hs.get(probe))
            .map(probe_count)
            .unwrap_or_else(|| "unknown".to_string());
        out.push_str(&format!("- **{probe}**: {count}\n"));
    }
    out.push('\n');

    // ----- Section 4: guest events count + first <=5 sample types -----------
    out.push_str("## Guest Events\n");
    out.push('\n');
    let guest_events = data.get("guestEvents").and_then(|v| v.as_array());
    let guest_total = guest_events.map(|a| a.len()).unwrap_or(0);
    out.push_str(&format!("- **total**: {guest_total}\n"));
    out.push_str("- **sample (first 5 types)**:\n");
    if let Some(arr) = guest_events {
        for entry in arr.iter().take(5) {
            let ty = entry
                .get("type")
                .and_then(|v| v.as_str())
                .unwrap_or("unknown");
            out.push_str(&format!("  - `{ty}`\n"));
        }
    }
    out.push('\n');

    // ----- Section 5: residue-class footer ---------------------------------
    let residue = data
        .get("residueClass")
        .and_then(|v| v.as_str())
        .unwrap_or("unknown");
    out.push_str("## Residue Class\n");
    out.push('\n');
    out.push_str(&format!("- **residueClass**: `{residue}`\n"));
    out.push('\n');

    // ----- Section 6: signing / integrity attestations ----------------------
    out.push_str("## Attestations\n");
    out.push('\n');
    let attestations = data.get("attestations");
    let env_sig = attestations
        .and_then(|a| a.get("envelopeSignature"))
        .and_then(|v| v.as_str())
        .unwrap_or("unknown");
    let attest_spec_sig = attestations
        .and_then(|a| a.get("specSignatureHash"))
        .and_then(|v| v.as_str())
        .unwrap_or("unknown");
    out.push_str(&format!("- **envelopeSignature**: `{env_sig}`\n"));
    out.push_str(&format!("- **specSignatureHash**: `{attest_spec_sig}`\n"));

    out
}

/// Read a string-typed field from `data`, returning `"unknown"` when absent
/// or non-string. Centralising this keeps section renderers panic-free.
fn str_field(data: &Value, key: &str) -> String {
    data.get(key)
        .and_then(|v| v.as_str())
        .unwrap_or("unknown")
        .to_string()
}

/// Render a host-probe series count. The wire shape is intentionally loose:
/// arrays surface their length, objects with a numeric `count` surface that,
/// numbers surface verbatim, and anything else is `"unknown"`.
fn probe_count(probe: &Value) -> String {
    if let Some(arr) = probe.as_array() {
        return format!("{} sample(s)", arr.len());
    }
    if let Some(n) = probe.as_u64() {
        return format!("{n} sample(s)");
    }
    if let Some(obj) = probe.as_object() {
        if let Some(n) = obj.get("count").and_then(|v| v.as_u64()) {
            return format!("{n} sample(s)");
        }
        if let Some(arr) = obj.get("samples").and_then(|v| v.as_array()) {
            return format!("{} sample(s)", arr.len());
        }
    }
    "unknown".to_string()
}