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,
}))
}