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