crates/gunnar-ui-web/src/pages/chain.rs
//! The chain view — gunnar's whole promise, rendered one link at a time.
//!
//! A line of source, the commit that wrote it, the signature on that commit,
//! the push attestation that landed it, the signed transparency-log head that
//! covers the push. Gunnar is only a git server: it never verifies a build
//! record (builds run in dwarves through Build Thing, artifacts live in
//! Holger), so the chain stops at the log. The walk is gunnar's
//! (`Admin.VerifyChain`, the same code `gunnar verify` prints from); this page
//! renders its verdicts and computes none of its own.
//!
//! # A hole renders as a hole
//!
//! Every link shows one of three words: `proven`, `BROKEN`, `unproven`. An
//! unsigned commit is an `unproven` signature link with gunnar's words beside
//! it — never an absent row, and never a quiet green. Absent rows are how a
//! provenance page lies without a single false pixel on it.
use leptos::prelude::*;
use leptos_router::hooks::{use_params_map, use_query_map};
use serde::Deserialize;
use crate::api::segment;
use crate::pages::resolved;
use crate::ui::{Badge, Hint, PageHeader, Panel, Tone};
/// One link, as `/api/repos/{ns}/{name}/chain` answers it.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Link {
pub id: String,
pub verdict: String,
pub detail: String,
}
/// The chain, as far as gunnar could walk it.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Chain {
pub repo: String,
pub from: String,
pub target: Option<String>,
pub summary: String,
pub links: Vec<Link>,
}
/// The label a link id renders under.
///
/// Unknown ids keep their id as the label: a link gunnar adds later must show
/// up as an unfamiliar row rather than not show up.
fn label(id: &str) -> String {
match id {
"line" => "1 · line → commit".to_owned(),
"signature" => "2 · commit signed".to_owned(),
"push" => "3 · push attested".to_owned(),
"log" => "4 · in the transparency log".to_owned(),
other => other.to_owned(),
}
}
/// The verdict's word and colour. The word does the work; the colour repeats it.
fn verdict_badge(verdict: &str) -> (Tone, &'static str) {
match verdict {
"proven" => (Tone::Pass, "proven"),
"broken" => (Tone::Bad, "BROKEN"),
// A hole: nothing was found wrong, nothing was found right.
_ => (Tone::Warn, "unproven"),
}
}
/// The one-line meaning of the chain's summary, in gunnar's own terms.
fn summary_line(summary: &str) -> (Tone, &'static str, &'static str) {
match summary {
"unbroken" => (Tone::Pass, "UNBROKEN", "Every link is proven."),
"broken" => (
Tone::Bad,
"BROKEN",
"A link was checked and did not hold. See the BROKEN row below.",
),
_ => (
Tone::Warn,
"INCOMPLETE",
"Nothing is wrong, and not everything is proven. An unchecked link is a hole, \
not a pass.",
),
}
}
#[component]
pub fn ChainView() -> impl IntoView {
let params = use_params_map();
let query = use_query_map();
let namespace = Memo::new(move |_| params.read().get("namespace").unwrap_or_default());
let name = Memo::new(move |_| params.read().get("name").unwrap_or_default());
let rev = Memo::new(move |_| query.read().get("rev").unwrap_or_default());
let line = Memo::new(move |_| query.read().get("line").unwrap_or_default());
let typed_rev = RwSignal::new(String::new());
let typed_line = RwSignal::new(String::new());
Effect::new(move |_| typed_rev.set(rev.get()));
Effect::new(move |_| typed_line.set(line.get()));
let navigate = leptos_router::hooks::use_navigate();
let walk = move || {
let target = format!(
"/{}/{}/chain?rev={}&line={}",
segment(&namespace.get_untracked()),
segment(&name.get_untracked()),
crate::auth::percent_encode(typed_rev.get_untracked().trim()),
crate::auth::percent_encode(typed_line.get_untracked().trim()),
);
navigate(&target, Default::default());
};
let walk_click = walk.clone();
let chain = LocalResource::new(move || {
let url = format!(
"/api/repos/{}/{}/chain?rev={}&line={}",
segment(&namespace.get()),
segment(&name.get()),
crate::auth::percent_encode(&rev.get()),
crate::auth::percent_encode(&line.get()),
);
async move { crate::api::get::<Chain>(&url).await }
});
view! {
<PageHeader
title=Signal::derive(move || format!("{}/{}", namespace.get(), name.get()))
/>
<crate::pages::repo::RepoTabs/>
<div class="stack">
<Panel title="Walk from">
<div class="panel-body row" style="align-items:flex-end">
<div class="field">
<label for="rev">"Revision"</label>
<input
id="rev"
type="text"
placeholder="branch, tag or commit"
prop:value=move || typed_rev.get()
on:input=move |ev| typed_rev.set(event_target_value(&ev))
/>
</div>
<div class="field">
<label for="line">"Line (path:number, optional)"</label>
<input
id="line"
type="text"
placeholder="src/lib.rs:42"
prop:value=move || typed_line.get()
on:input=move |ev| typed_line.set(event_target_value(&ev))
on:keydown={
let walk = walk.clone();
move |ev: leptos::ev::KeyboardEvent| {
if ev.key() == "Enter" {
walk();
}
}
}
/>
</div>
<button class="btn primary" on:click=move |_| walk_click()>
"Walk the chain"
</button>
</div>
<Hint
line="Without a line, link 1 is an unchecked hole."
more="Without a line, the walk starts at the revision itself and link 1 is an \
unchecked hole — Gunnar says so rather than passing it."
/>
</Panel>
{move || resolved(chain.get(), None, |chain: Chain| {
let (tone, word, meaning) = summary_line(&chain.summary);
view! {
<Panel title="Verdict">
<div class="panel-body">
<p>
<Badge tone=tone label=word.to_owned()/>
" "
<span data-chain-summary=chain.summary.clone()>{meaning}</span>
</p>
<p class="truncation">
{format!("repo {} · from {}", chain.repo, chain.from)}
{chain.target.as_ref().map(|t| format!(" · target {t}")).unwrap_or_default()}
</p>
</div>
</Panel>
<Panel title="The chain">
<div class="table-wrap">
<table>
<caption class="vh">"Chain links"</caption>
<thead>
<tr>
<th scope="col">"Link"</th>
<th scope="col">"Verdict"</th>
<th scope="col">"What Gunnar found"</th>
</tr>
</thead>
<tbody>
{chain.links
.into_iter()
.map(|link| {
let (tone, word) = verdict_badge(&link.verdict);
view! {
<tr data-chain-link=link.id.clone() data-chain-verdict=link.verdict.clone()>
<td>{label(&link.id)}</td>
<td><Badge tone=tone label=word.to_owned()/></td>
<td>{link.detail}</td>
</tr>
}
})
.collect_view()}
</tbody>
</table>
</div>
</Panel>
}
})}
</div>
}
}