Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

crates/gunnar-ui-server/src/api/chain.rs

//! The chain view: `gunnar verify`, served to a browser.
//!
//! gunnar's whole promise is the unbroken chain — a line of source, the commit
//! that wrote it, the signature on that commit, the push attestation that
//! landed it, and the signed transparency-log head covering the push. The walk
//! itself lives in gunnar (`gunnar-server`'s `verify` module, the same code
//! `gunnar verify` prints from) and is served as `Admin.VerifyChain`; this
//! handler reshapes the verdicts for the page and adds NOTHING — a second
//! opinion about a provenance chain is exactly the twin this console must
//! never grow.
//!
//! # A hole is an answer
//!
//! Every link carries one of three verdicts, and the console keeps all three:
//! `proven`, `broken`, and `unproven`. Collapsing `unproven` into either
//! neighbour would make an unsigned commit render like a forged one — or
//! worse, like a clean one. The page renders each verdict in words; this
//! handler's only job is not to lose them on the way.

use axum::extract::{Path, Query};
use serde::{Deserialize, Serialize};

use crate::api::{repo_path, ApiError, ApiResult, Reader};
use crate::pb;

/// One link of the chain, with its verdict spelled out.
#[derive(Debug, Clone, Serialize)]
pub struct Link {
    /// gunnar's stable link id: `line`, `signature`, `push`, `log`.
    pub id: String,
    /// `proven` | `broken` | `unproven`. Never empty: an unknown wire value is
    /// reported as a refusal rather than guessed at.
    pub verdict: String,
    /// gunnar's finding, verbatim — which commit wrote the line, whose key
    /// signed it, which record of the log covers the push, or why the link
    /// could not be checked.
    pub detail: String,
}

/// The chain, as far as gunnar could walk it.
#[derive(Debug, Clone, Serialize)]
pub struct Chain {
    pub repo: String,
    pub from: String,
    pub target: Option<String>,
    /// `unbroken` | `broken` | `incomplete`. gunnar's three-way summary,
    /// kept three-way: incomplete is not unbroken, because an unchecked link
    /// is a hole and not a pass.
    pub summary: String,
    pub links: Vec<Link>,
}

/// `?rev=` and `?line=` on the chain view.
#[derive(Debug, Clone, Deserialize)]
pub struct ChainQuery {
    /// A branch, tag, `HEAD` or hex oid. gunnar defaults an empty one to
    /// `HEAD`.
    #[serde(default)]
    pub rev: Option<String>,
    /// `<path>:<line>`, one-based. Without it link 1 is unproven by
    /// construction, and gunnar says so in words.
    #[serde(default)]
    pub line: Option<String>,
}

fn verdict_name(raw: i32) -> Result<&'static str, ApiError> {
    match pb::ChainLinkVerdict::try_from(raw) {
        Ok(pb::ChainLinkVerdict::ChainLinkProven) => Ok("proven"),
        Ok(pb::ChainLinkVerdict::ChainLinkBroken) => Ok("broken"),
        Ok(pb::ChainLinkVerdict::ChainLinkUnproven) => Ok("unproven"),
        // A verdict this console does not know is not a verdict it may guess
        // at. Refusing is the only rendering that cannot lie.
        Ok(pb::ChainLinkVerdict::Unspecified) | Err(_) => Err(ApiError(crate::grpc::Fail {
            code: "internal".to_owned(),
            message: format!(
                "Gunnar answered a chain-link verdict this console does not know ({raw}); \
                 update the console"
            ),
        })),
    }
}

fn summary_name(raw: i32) -> Result<&'static str, ApiError> {
    match pb::ChainSummary::try_from(raw) {
        Ok(pb::ChainSummary::ChainUnbroken) => Ok("unbroken"),
        Ok(pb::ChainSummary::ChainBroken) => Ok("broken"),
        Ok(pb::ChainSummary::ChainIncomplete) => Ok("incomplete"),
        Ok(pb::ChainSummary::Unspecified) | Err(_) => Err(ApiError(crate::grpc::Fail {
            code: "internal".to_owned(),
            message: format!(
                "Gunnar answered a chain summary this console does not know ({raw}); update \
                 the console"
            ),
        })),
    }
}

/// `GET /api/repos/{namespace}/{name}/chain?rev=<rev>&line=<path>:<n>`
///
/// Not cached: every ask is an operator deliberately re-checking provenance,
/// and serving a stale verdict about a chain is worse than spending the call.
pub async fn walk(
    reader: Reader,
    Path((namespace, name)): Path<(String, String)>,
    Query(query): Query<ChainQuery>,
) -> ApiResult<Chain> {
    ask(
        &reader,
        pb::VerifyChainRequest {
            repo: Some(repo_path(&namespace, &name)),
            rev: query.rev.unwrap_or_default(),
            target: query.line.unwrap_or_default(),
        },
    )
    .await
}

/// The one call and the reshaping.
async fn ask(reader: &Reader, request: pb::VerifyChainRequest) -> ApiResult<Chain> {
    let response = reader
        .gunnar
        .admin()
        .verify_chain(request)
        .await
        .map_err(ApiError::from)?
        .into_inner();

    let mut links = Vec::with_capacity(response.links.len());
    for link in response.links {
        links.push(Link {
            verdict: verdict_name(link.verdict)?.to_owned(),
            id: link.id,
            detail: link.detail,
        });
    }
    Ok(axum::Json(Chain {
        repo: response.repo,
        from: response.from,
        target: (!response.target.is_empty()).then_some(response.target),
        summary: summary_name(response.summary)?.to_owned(),
        links,
    }))
}