OXYGENHUB

Coverage / crates/hub/src/web/release_trust.rs 83.3%

414 of 497 lines run, 58 of 136 functions · run #542 on main · run missed

1
//! A release's trust report (hub spec §9.1): who wrote what the release
2
//! holds and how it is authenticated, what built each asset, in which
3
//! pinned environment, from which inputs, under which signed receipt, and
4
//! an audit of all of it. The page draws it as a graph; the same report is
5
//! JSON for export, carrying the signed receipts so an auditor can check
6
//! them offline with `oxy receipt verify`.
7
8
use super::{filters, lookup, Error, Page, RepoShell, Shell};
9
use crate::auth::Viewer;
10
use crate::db::{self, flow as fdb, releases as rdb};
11
use crate::repos::RepoName;
12
use crate::Shared;
13
use askama::Template;
14
use axum::extract::{Path, Query, State};
15
use axum::response::{IntoResponse, Response};
16
use oxy_core::hash::{ChangeHash, ObjectRef};
17
use oxy_core::identity::{verify, Domain};
18
use oxy_core::repo::SignatureStatus;
19
use serde::{Deserialize, Serialize};
20
use std::collections::{BTreeMap, HashMap};
21
22
/// A key as the report names it.
23
#[derive(Clone, Debug, Default, Serialize)]
24
pub struct KeyInfo {
25
    pub key: String,
26
    pub name: String,
27
    pub email: String,
28
    /// The account the key is linked to on this hub.
29
    pub account: Option<String>,
30
}
31
32
#[derive(Clone, Debug, Default, Serialize)]
33
pub struct AuthorTrust {
34
    pub who: KeyInfo,
35
    pub changes: usize,
36
    /// Changes authenticated by their own signature.
37
    pub signed: usize,
38
    /// Changes authenticated by a seal.
39
    pub sealed: usize,
40
    pub unsigned: usize,
41
    /// Authenticated by a key since revoked.
42
    pub revoked: usize,
43
    pub invalid: usize,
44
}
45
46
#[derive(Clone, Debug, Serialize)]
47
pub struct BuildTrust {
48
    pub run: i64,
49
    pub job: String,
50
    pub run_url: String,
51
    /// The heads the run built, joined with `+`.
52
    pub heads: String,
53
    /// Whether that is exactly the tagged state.
54
    pub same_state_as_tag: bool,
55
    pub pipeline_hash: String,
56
    pub scope_tree_hash: String,
57
    /// Hash of the environment the job ran in (runtime, pinned parts,
58
    /// rootfs, argv, env).
59
    pub environment: String,
60
    pub rootfs: String,
61
    pub runtime: String,
62
    pub exit_code: Option<i64>,
63
    pub runner: Option<KeyInfo>,
64
    /// The receipt attestation, when a signed receipt was kept.
65
    pub receipt: Option<String>,
66
    pub receipt_url: Option<String>,
67
    /// Why the kept receipt does not verify now, if it does not.
68
    pub receipt_problem: Option<String>,
69
    pub runner_revoked: bool,
70
    /// The runner's key is one a runner token on this hub signs with, or a
71
    /// short-lived key this hub certified for the repository (#241).
72
    pub runner_known: bool,
73
    /// The subject this hub certified the short-lived key that signed the
74
    /// receipt for, when it was one.
75
    pub certified_for: Option<String>,
76
    /// The signed receipt, hex, for `oxy receipt verify`.
77
    pub signed_receipt: Option<String>,
78
}
79
80
#[derive(Clone, Debug, Serialize)]
81
pub struct AssetTrust {
82
    pub name: String,
83
    pub size: i64,
84
    pub blake3: String,
85
    pub url: String,
86
    pub build: Option<BuildTrust>,
87
}
88
89
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
90
#[serde(rename_all = "lowercase")]
91
pub enum Verdict {
92
    Pass,
93
    Warn,
94
    Fail,
95
}
96
97
#[derive(Clone, Debug, Serialize)]
98
pub struct Check {
99
    pub verdict: Verdict,
100
    pub title: String,
101
    pub detail: String,
102
}
103
104
impl Check {
10513×
    pub fn glyph(&self) -> &'static str {
10613×
        match self.verdict {
10712×
            Verdict::Pass => "✔",
1081×
            Verdict::Warn => "!",
1090×
            Verdict::Fail => "✘",
110
        }
11113×
    }
11213×
    pub fn class(&self) -> &'static str {
11313×
        match self.verdict {
11412×
            Verdict::Pass => "pass",
1151×
            Verdict::Warn => "warn",
1160×
            Verdict::Fail => "fail",
117
        }
11813×
    }
119
}
120
121
/// One claim of the trust summary: `pass`, `warn` or `fail`, and what it says.
122
pub struct TrustBadge {
123
    pub class: &'static str,
124
    pub text: String,
125
}
126
127
#[derive(Clone, Debug, Serialize)]
128
pub struct ReleaseTrust {
129
    pub repository: String,
130
    pub tag: String,
131
    pub title: String,
132
    pub release_url: String,
133
    pub heads: Vec<String>,
134
    pub tag_signer: Option<KeyInfo>,
135
    /// `valid`, `unsigned`, `invalid: …` or `revoked`.
136
    pub tag_signature: String,
137
    /// The previous release, which bounds the changes counted.
138
    pub since: Option<String>,
139
    pub changes: usize,
140
    pub authors: Vec<AuthorTrust>,
141
    pub assets: Vec<AssetTrust>,
142
    pub audit: Vec<Check>,
143
    pub generated_ms: i64,
144
}
145
146
impl ReleaseTrust {
1472×
    pub fn verdict_class(&self) -> &'static str {
1482×
        match self.verdict() {
1491×
            Verdict::Pass => "pass",
1501×
            Verdict::Warn => "warn",
1510×
            Verdict::Fail => "fail",
152
        }
1532×
    }
154
155
    /// The page's trust summary, one badge per claim: the tag's signature,
156
    /// how many changes are authenticated, and how many assets an attested
157
    /// OxygenFlow build produced (oxygenhub#79).
1582×
    pub fn summary(&self) -> Vec<TrustBadge> {
1592×
        let tag = match self.tag_signature.as_str() {
1602×
            "valid" => ("pass", format!("Tag signed by {}", self.tag_signer.as_ref().map(|k| k.name.as_str()).unwrap_or("its signer"))),
1610×
            "unsigned" => ("warn", "Tag unsigned".to_string()),
1620×
            other => ("fail", format!("Tag signature {other}")),
163
        };
1642×
        let authenticated: usize = self.authors.iter().map(|a| a.signed + a.sealed).sum();
1652×
        let bad: usize = self.authors.iter().map(|a| a.invalid + a.revoked).sum();
1662×
        let changes = (
1672×
            if bad > 0 {
1680×
                "fail"
1692×
            } else if authenticated < self.changes {
1700×
                "warn"
171
            } else {
1722×
                "pass"
173
            },
1742×
            format!("{authenticated} of {} changes signed or sealed", self.changes),
175
        );
1762×
        let attested =
1773×
            self.assets.iter().filter(|a| a.build.as_ref().is_some_and(|b| b.receipt.is_some() && b.receipt_problem.is_none())).count();
1782×
        let assets = (
1792×
            if attested == self.assets.len() { "pass" } else { "warn" },
1802×
            if self.assets.is_empty() {
1810×
                "No assets".to_string()
182
            } else {
1832×
                format!("{attested} of {} assets built and attested by OxygenFlow", self.assets.len())
184
            },
185
        );
1866×
        [tag, changes, assets].into_iter().map(|(class, text)| TrustBadge { class, text }).collect()
1872×
    }
188
1892×
    pub fn verdict(&self) -> Verdict {
19013×
        if self.audit.iter().any(|c| c.verdict == Verdict::Fail) {
1910×
            Verdict::Fail
19210×
        } else if self.audit.iter().any(|c| c.verdict == Verdict::Warn) {
1931×
            Verdict::Warn
194
        } else {
1951×
            Verdict::Pass
196
        }
1972×
    }
198
}
199
200
/// What the repository says about the release: read in one go.
201
struct RepoFacts {
202
    heads: Vec<ChangeHash>,
203
    tag_signer: Option<KeyInfo>,
204
    tag_signature: String,
205
    authors: Vec<AuthorTrust>,
206
    changes: usize,
207
    /// Kept receipts by attestation: (runner, problem, revoked, signed hex).
208
    receipts: HashMap<String, KeptReceipt>,
209
}
210
21110×
fn key_info(r: &oxy_core::repo::Repo, key: &ObjectRef) -> KeyInfo {
21210×
    match r.store().get_identity(key) {
21310×
        Ok(i) => KeyInfo { key: key.to_hex(), name: i.name, email: i.email, account: None },
2140×
        Err(_) => KeyInfo { key: key.to_hex(), ..Default::default() },
215
    }
21610×
}
217
218
/// An asset's build: the matching artifact's job, with its run and job rows.
219
type Build = (fdb::Provenance, Option<fdb::Run>, Option<fdb::Job>);
220
221
/// Build the report for release `tag`, or `None` when there is no such
222
/// release or its tag is gone.
223
/// A kept receipt as the report sees it: its signer, why it does not
224
/// verify (if it does not), whether the key was revoked, the signed
225
/// receipt in hex, and the issuer and subject of a short-lived key's
226
/// certificate.
227
type KeptReceipt = (KeyInfo, Option<String>, bool, String, Option<(String, String)>);
228
2297×
pub async fn report(app: &Shared, repo: &RepoName, row: &db::repos::RepoRow, tag: &str) -> Result<Option<ReleaseTrust>, String> {
2307×
    let releases = rdb::list(&app.db, row.id).await.map_err(|e| e.to_string())?;
2317×
    let Some(rel) = releases.iter().find(|r| r.tag == tag).cloned() else { return Ok(None) };
232
    // The previous release: the next older one in the list (newest first).
2335×
    let since = releases.iter().skip_while(|r| r.id != rel.id).nth(1).map(|r| r.tag.clone());
2345×
    let assets = rdb::assets(&app.db, rel.id).await.map_err(|e| e.to_string())?;
2355×
    let mut builds: Vec<Option<Build>> = Vec::new();
2368×
    for a in &assets {
2378×
        match fdb::provenance(&app.db, row.id, &a.hash).await.map_err(|e| e.to_string())? {
2384×
            Some(p) => {
2394×
                let run = fdb::run(&app.db, row.id, p.run_number).await.map_err(|e| e.to_string())?;
2404×
                let job = match &run {
2414×
                    Some(r) => fdb::jobs(&app.db, r.id).await.map_err(|e| e.to_string())?.into_iter().find(|j| j.name == p.job),
2420×
                    None => None,
243
                };
2444×
                builds.push(Some((p, run, job)));
245
            }
2464×
            None => builds.push(None),
247
        }
248
    }
2495×
    let attests: Vec<String> = builds.iter().flatten().map(|(p, _, _)| p.receipt_attest.clone()).filter(|a| !a.is_empty()).collect();
2505×
    let (tag_s, since_s) = (tag.to_string(), since.clone());
2515×
    let facts = app
2525×
        .repos
2535×
        .read(repo, move |r, cache| {
2545×
            let Some(heads) = r.tag_heads(&tag_s)? else { return Ok(None) };
255
            // The tag's attestation and whether its signature verifies.
2565×
            let (tag_signer, tag_signature) = match r.tag_attest(&tag_s)? {
2575×
                Some((ar, sa)) => match sa.sigs.first() {
2585×
                    Some(sig) => {
2595×
                        let who = key_info(r, &sig.key);
2605×
                        let verdict = match r.store().get_identity(&sig.key) {
2615×
                            Ok(id) => match verify(&id, sig, Domain::Attest, &ar) {
2625×
                                Ok(()) if r.is_revoked(&sig.key)? => "revoked".to_string(),
2635×
                                Ok(()) => "valid".to_string(),
2640×
                                Err(e) => format!("invalid: {e}"),
265
                            },
2660×
                            Err(_) => "invalid: the signer's identity is missing".to_string(),
267
                        };
2685×
                        (Some(who), verdict)
269
                    }
2700×
                    None => (None, "unsigned".to_string()),
271
                },
2720×
                None => (None, "unsigned".to_string()),
273
            };
274
            // The changes the release adds over the previous one, by author.
2755×
            let now = cache.load(r, &heads)?;
2765×
            let before = match &since_s {
2770×
                Some(p) => r.tag_heads(p)?.unwrap_or_default(),
2785×
                None => vec![],
279
            };
2805×
            let then = cache.load(r, &before)?;
2815×
            let mut by_author: BTreeMap<String, AuthorTrust> = BTreeMap::new();
2825×
            let mut changes = 0;
2835×
            for (h, meta) in now.state.changes() {
2845×
                if then.state.contains(h) {
2850×
                    continue;
2865×
                }
2875×
                changes += 1;
2885×
                let a = by_author
2895×
                    .entry(meta.author.to_hex())
2905×
                    .or_insert_with(|| AuthorTrust { who: key_info(r, &meta.author), ..Default::default() });
2915×
                if meta.author.is_zero() {
2920×
                    a.who.name = "(no author: imported or made before an identity)".into();
2935×
                }
2945×
                a.changes += 1;
2955×
                match r.authenticate_change(h)? {
2960×
                    SignatureStatus::Valid { revoked: true, .. } => a.revoked += 1,
2975×
                    SignatureStatus::Valid { sealed: true, .. } => a.sealed += 1,
2980×
                    SignatureStatus::Valid { .. } => a.signed += 1,
2990×
                    SignatureStatus::Unsigned => a.unsigned += 1,
3000×
                    SignatureStatus::Invalid(_) => a.invalid += 1,
301
                }
302
            }
3035×
            let mut authors: Vec<AuthorTrust> = by_author.into_values().collect();
3045×
            authors.sort_by(|a, b| b.changes.cmp(&a.changes).then(a.who.name.cmp(&b.who.name)));
305
            // Kept receipts, verified again from the store.
3065×
            let mut receipts = HashMap::new();
3075×
            for a in &attests {
3084×
                let Some(ar) = ObjectRef::from_hex(a) else { continue };
3094×
                if let Ok(i) = r.receipt(&ar) {
3104×
                    let runner = KeyInfo {
3114×
                        key: i.signed.runner.hash().to_hex(),
3124×
                        name: i.signed.runner.name.clone(),
3134×
                        email: i.signed.runner.email.clone(),
3144×
                        account: None,
3154×
                    };
316
                    // A short-lived key's certificate: its issuer and subject.
3174×
                    let certified = i.signed.certificate.as_ref().map(|c| (c.cert.cert.issuer.to_hex(), c.cert.cert.subject.clone()));
3184×
                    receipts.insert(a.clone(), (runner, i.problem.clone(), i.revoked, hex::encode(i.signed.encode()), certified));
3190×
                }
320
            }
3215×
            Ok(Some(RepoFacts { heads, tag_signer, tag_signature, authors, changes, receipts }))
3225×
        })
3235×
        .await
3245×
        .map_err(|e| e.to_string())?;
3255×
    let Some(mut facts) = facts else { return Ok(None) };
326
327
    // Link keys to accounts.
3285×
    let links: HashMap<String, String> = db::accounts::identity_accounts(&app.db).await.unwrap_or_default().into_iter().collect();
3295×
    if let Some(s) = facts.tag_signer.as_mut() {
3305×
        s.account = links.get(&s.key).cloned();
3315×
    }
3325×
    for a in &mut facts.authors {
3335×
        a.who.account = links.get(&a.who.key).cloned();
3345×
    }
3355×
    let known_runner_keys: Vec<String> = db::runners::keys(&app.db).await.unwrap_or_default();
336
3375×
    let base = format!("{}/{}", app.config.base_url(), row.slug());
3385×
    let mut tag_heads: Vec<String> = facts.heads.iter().map(|h| h.to_hex()).collect();
3395×
    tag_heads.sort();
3405×
    let tag_state = tag_heads.join("+");
3415×
    let mut asset_rows = Vec::new();
3428×
    for (a, b) in assets.iter().zip(builds) {
3438×
        let build = b.map(|(p, run, job)| {
3444×
            let receipt_json: serde_json::Value =
3454×
                job.as_ref().and_then(|j| serde_json::from_str(&j.receipt).ok()).unwrap_or(serde_json::Value::Null);
3464×
            let sandbox: serde_json::Value =
3474×
                job.as_ref().and_then(|j| serde_json::from_str(&j.sandbox).ok()).unwrap_or(serde_json::Value::Null);
3484×
            let heads = run.as_ref().map(|r| r.heads.clone()).unwrap_or_default();
3494×
            let kept = facts.receipts.get(&p.receipt_attest).cloned();
350
            // Certified by this hub's key for this repository: as good as
351
            // a runner key of this hub (the certificate itself was checked
352
            // with the receipt).
3534×
            let certified_for = kept
3544×
                .as_ref()
3554×
                .and_then(|(.., c)| c.clone())
3564×
                .filter(|(issuer, subject)| *issuer == app.hub_key.key.to_hex() && subject.starts_with(&format!("repo:{}:", row.slug())));
3574×
            let certified_for = certified_for.map(|(_, subject)| subject);
35820×
            let s = |v: &serde_json::Value| v.as_str().unwrap_or("").to_string();
359
            BuildTrust {
3604×
                run: p.run_number,
3614×
                job: p.job.clone(),
3624×
                run_url: format!("{base}/flow/{}", p.run_number),
3634×
                same_state_as_tag: heads == tag_state,
3644×
                heads,
3654×
                pipeline_hash: s(&receipt_json["pipeline_hash"]),
3664×
                scope_tree_hash: s(&receipt_json["scope_tree_hash"]),
3674×
                environment: s(&receipt_json["environment"]),
3684×
                rootfs: s(&sandbox["rootfs"]),
3694×
                runtime: s(&sandbox["runtime"]),
3704×
                exit_code: job.as_ref().and_then(|j| j.exit_code),
3714×
                runner_known: kept.as_ref().is_some_and(|(k, ..)| known_runner_keys.contains(&k.key)) || certified_for.is_some(),
3724×
                certified_for,
3734×
                runner: kept.as_ref().map(|(k, ..)| k.clone()),
3744×
                receipt: kept.as_ref().map(|_| p.receipt_attest.clone()),
3754×
                receipt_url: kept.as_ref().map(|_| format!("{base}/receipts/{}", p.receipt_attest)),
3764×
                receipt_problem: kept.as_ref().and_then(|(_, pr, ..)| pr.clone()),
3774×
                runner_revoked: kept.as_ref().is_some_and(|(_, _, rv, ..)| *rv),
3784×
                signed_receipt: kept.map(|(_, _, _, hex, _)| hex),
379
            }
3804×
        });
3818×
        asset_rows.push(AssetTrust {
3828×
            name: a.name.clone(),
3838×
            size: a.size,
3848×
            blake3: a.hash.clone(),
3858×
            url: format!("{base}/asset/{}/{}", a.id, a.name),
3868×
            build,
3878×
        });
388
    }
3895×
    let mut t = ReleaseTrust {
3905×
        repository: row.slug(),
3915×
        tag: tag.to_string(),
3925×
        title: rel.title.clone(),
3935×
        release_url: format!("{base}/releases/tag/{tag}"),
3945×
        heads: tag_heads,
3955×
        tag_signer: facts.tag_signer,
3965×
        tag_signature: facts.tag_signature,
3975×
        since,
3985×
        changes: facts.changes,
3995×
        authors: facts.authors,
4005×
        assets: asset_rows,
4015×
        audit: vec![],
4025×
        generated_ms: db::now_ms(),
4035×
    };
4045×
    t.audit = audit(&t);
4055×
    Ok(Some(t))
4067×
}
407
408
/// The audit: every claim the report makes, judged.
4095×
pub fn audit(t: &ReleaseTrust) -> Vec<Check> {
4105×
    let mut out = Vec::new();
41131×
    let check = |verdict, title: String, detail: String| Check { verdict, title, detail };
4125×
    let signer = t.tag_signer.as_ref().map(|k| if k.name.is_empty() { k.key[..12].to_string() } else { k.name.clone() });
4135×
    out.push(match t.tag_signature.as_str() {
4145×
        "valid" => check(Verdict::Pass, format!("Tag {} is signed", t.tag), format!("by {}", signer.unwrap_or_default())),
4150×
        "unsigned" => check(
4160×
            Verdict::Warn,
4170×
            format!("Tag {} is not signed", t.tag),
4180×
            "Create tags with a key (`oxy identity create`) so they carry a signed attestation.".into(),
4190×
        ),
4200×
        "revoked" => check(Verdict::Fail, format!("Tag {} is signed by a revoked key", t.tag), signer.unwrap_or_default()),
4210×
        other => check(Verdict::Fail, format!("Tag {}'s signature does not verify", t.tag), other.to_string()),
422
    });
423
    // The heads once, here, rather than on every asset's line (oxygenhub#93).
4245×
    let short = |h: &String| h[..12.min(h.len())].to_string();
4255×
    out.push(match t.heads.len() {
4265×
        1 => check(Verdict::Pass, "The tagged state is one change".into(), short(&t.heads[0])),
4270×
        n => check(Verdict::Pass, format!("The tagged state has {n} heads"), t.heads.iter().map(short).collect::<Vec<_>>().join(" ")),
428
    });
42915×
    let sum = |f: fn(&AuthorTrust) -> usize| t.authors.iter().map(f).sum::<usize>();
4305×
    let (unsigned, invalid, revoked) = (sum(|a| a.unsigned), sum(|a| a.invalid), sum(|a| a.revoked));
4315×
    let scope = match &t.since {
4320×
        Some(p) => format!("since {p}"),
4335×
        None => "in the release".to_string(),
434
    };
4355×
    out.push(if invalid > 0 || revoked > 0 {
4360×
        check(
4370×
            Verdict::Fail,
4380×
            format!("{} of {} changes {scope} fail authentication", invalid + revoked, t.changes),
4390×
            format!("{invalid} with an invalid signature, {revoked} by revoked keys"),
4400×
        )
4415×
    } else if unsigned > 0 {
4420×
        check(
4430×
            Verdict::Warn,
4440×
            format!("{unsigned} of {} changes {scope} are unsigned", t.changes),
4450×
            "Changes are authenticated when their author signs or seals them on push (Oxygen spec §14.4).".into(),
4460×
        )
447
    } else {
4485×
        let what = match t.changes {
4495×
            1 => format!("The one change {scope} is authenticated"),
4500×
            n => format!("All {n} changes {scope} are authenticated"),
451
        };
4525×
        let by = match t.authors.len() {
4535×
            1 => "by one author".to_string(),
4540×
            n => format!("by {n} authors"),
455
        };
4565×
        check(Verdict::Pass, what, by)
457
    });
4585×
    if t.assets.is_empty() {
4590×
        out.push(check(Verdict::Warn, "The release has no assets".into(), "Nothing to trace to a build.".into()));
4605×
    }
4618×
    for a in &t.assets {
4628×
        match &a.build {
4634×
            None => out.push(check(
4644×
                Verdict::Warn,
4654×
                format!("{}: no build found", a.name),
4664×
                "No passed OxygenFlow job of this repository produced these bytes; it was uploaded by hand.".into(),
4674×
            )),
4684×
            Some(b) => {
4694×
                out.push(match (&b.receipt, &b.receipt_problem, b.runner_revoked, b.runner_known) {
4700×
                    (None, ..) => check(
4710×
                        Verdict::Warn,
4720×
                        format!("{}: built by run #{} {}, without a signed receipt", a.name, b.run, b.job),
4730×
                        "The runner did not sign its receipt, or the hub did not keep it.".into(),
4740×
                    ),
4750×
                    (Some(_), Some(p), ..) => check(Verdict::Fail, format!("{}: the signed receipt does not verify", a.name), p.clone()),
476
                    (Some(_), None, true, _) => {
4770×
                        check(Verdict::Fail, format!("{}: the receipt's runner key was revoked", a.name), String::new())
478
                    }
4794×
                    (Some(_), None, false, known) => check(
4804×
                        if known { Verdict::Pass } else { Verdict::Warn },
4814×
                        format!("{}: signed receipt from run #{} {}", a.name, b.run, b.job),
4824×
                        format!(
483
                            "signed by {}{}",
4844×
                            b.runner.as_ref().map(|k| k.name.as_str()).unwrap_or(""),
4854×
                            match (&b.certified_for, known) {
4861×
                                (Some(s), _) => format!(", a short-lived key this hub certified for {s}"),
4873×
                                (None, true) => ", a runner key of this hub".to_string(),
4880×
                                (None, false) => "; that key is not one this hub's runners use".to_string(),
489
                            }
490
                        ),
491
                    ),
492
                });
4934×
                out.push(if b.same_state_as_tag {
4944×
                    check(Verdict::Pass, format!("{}: built from the tagged state", a.name), format!("by run #{}", b.run))
495
                } else {
4960×
                    let heads: Vec<String> = b.heads.split('+').map(|h| h[..12.min(h.len())].to_string()).collect();
4970×
                    let built = match heads.len() {
4980×
                        0 => "nothing".to_string(),
4990×
                        1 => heads[0].clone(),
5000×
                        n if n <= 4 => format!("{n} heads {}", heads.join(" ")),
5010×
                        n => format!("{n} heads {} … {}", heads[0], heads[n - 1]),
502
                    };
5030×
                    check(
5040×
                        Verdict::Warn,
5050×
                        format!("{}: built from another state than the tag's", a.name),
5060×
                        format!("run #{} built {built}", b.run),
5070×
                    )
508
                });
509256×
                let pinned = b.rootfs.len() == 64 && b.rootfs.bytes().all(|c| c.is_ascii_hexdigit()) && !b.environment.is_empty();
5104×
                out.push(if pinned {
5114×
                    check(Verdict::Pass, format!("{}: pinned environment", a.name), format!("{} rootfs {}", b.runtime, &b.rootfs[..12]))
512
                } else {
5130×
                    check(Verdict::Warn, format!("{}: environment not pinned by hash", a.name), b.rootfs.clone())
514
                });
515
            }
516
        }
517
    }
5185×
    out
5195×
}
520
521
/// The report as a graph: authors, the tagged state, the builds (when any
522
/// job built an asset) and the assets, as inline SVG drawn at its own size.
523
/// Each node carries a badge: a lock where the claim is signed and checked,
524
/// `!` where it is not (unsigned, uploaded by hand), `✘` where it fails;
525
/// its second line says why, and its title says it in full (oxygenhub#79).
5262×
pub fn graph(t: &ReleaseTrust) -> String {
527
    const W: f64 = 220.0;
528
    const H: f64 = 40.0;
529
    const GAP: f64 = 10.0;
530
    const COL: f64 = W + 48.0;
531
    struct Node {
532
        col: usize,
533
        row: usize,
534
        title: String,
535
        sub: String,
536
        class: &'static str,
537
        tip: String,
538
    }
5392×
    let esc = crate::render::escape;
5402×
    let mut nodes: Vec<Node> = Vec::new();
5412×
    let mut edges: Vec<(usize, usize, bool)> = Vec::new();
5423×
    let any_build = t.assets.iter().any(|a| a.build.is_some());
5432×
    let asset_col = if any_build { 3 } else { 2 };
5442×
    let authors: Vec<usize> = t
5452×
        .authors
5462×
        .iter()
5472×
        .take(12)
5482×
        .enumerate()
5492×
        .map(|(i, a)| {
5502×
            let name = if a.who.name.is_empty() { a.who.key[..12].to_string() } else { a.who.name.clone() };
5512×
            let (class, why) = if a.invalid + a.revoked > 0 {
5520×
                ("fail", format!("{} invalid or revoked", a.invalid + a.revoked))
5532×
            } else if a.unsigned > 0 {
5540×
                ("warn", format!("{} of {} unsigned", a.unsigned, a.changes))
555
            } else {
5562×
                ("pass", "all signed".to_string())
557
            };
5582×
            let linked = if a.who.account.is_some() { "key linked to an account" } else { "key not linked to an account" };
5592×
            nodes.push(Node {
560
                col: 0,
5612×
                row: i,
5622×
                title: name.clone(),
5632×
                sub: format!("{} change{} · {why}", a.changes, if a.changes == 1 { "" } else { "s" }),
5642×
                class,
5652×
                tip: format!("{name}: {} changes, {} signed, {} sealed, {} unsigned; {linked}", a.changes, a.signed, a.sealed, a.unsigned),
566
            });
5672×
            nodes.len() - 1
5682×
        })
5692×
        .collect();
5702×
    let signer = t.tag_signer.as_ref().map(|k| k.name.clone()).unwrap_or_default();
5712×
    let (tag_class, tag_sub) = match t.tag_signature.as_str() {
5722×
        "valid" => ("pass", format!("signed by {signer}")),
5730×
        "unsigned" => ("warn", "unsigned".to_string()),
5740×
        other => ("fail", other.to_string()),
575
    };
5762×
    nodes.push(Node {
5772×
        col: 1,
5782×
        row: 0,
5792×
        title: format!("tag {}", t.tag),
5802×
        sub: tag_sub.clone(),
5812×
        class: tag_class,
5822×
        tip: format!("tag {}: {} changes; {tag_sub}", t.tag, t.changes),
5832×
    });
5842×
    let tag = nodes.len() - 1;
5852×
    for a in authors {
5862×
        edges.push((a, tag, true));
5872×
    }
5882×
    let mut builds: HashMap<(i64, String), usize> = HashMap::new();
5892×
    let mut build_rows = 0;
5903×
    for (i, a) in t.assets.iter().enumerate() {
5913×
        let build = a.build.as_ref().map(|b| {
5922×
            *builds.entry((b.run, b.job.clone())).or_insert_with(|| {
5932×
                let runner = b.runner.as_ref().map(|r| r.name.clone()).unwrap_or_default();
5942×
                let (class, why) = match (&b.receipt, &b.receipt_problem) {
5952×
                    (Some(_), None) => ("pass", format!("receipt verified · {runner}")),
5960×
                    (Some(_), Some(p)) => ("fail", p.clone()),
5970×
                    _ => ("warn", "no signed receipt".to_string()),
598
                };
5992×
                nodes.push(Node {
600
                    col: 2,
6012×
                    row: build_rows,
6022×
                    title: format!("run #{} {}", b.run, b.job),
6032×
                    sub: why.clone(),
6042×
                    class,
6052×
                    tip: format!(
606
                        "run #{} job {}: {why}{}",
607
                        b.run,
608
                        b.job,
6092×
                        if b.same_state_as_tag { ", from the tagged state" } else { ", from another state than the tag's" }
610
                    ),
611
                });
6122×
                build_rows += 1;
6132×
                edges.push((tag, nodes.len() - 1, b.same_state_as_tag));
6142×
                nodes.len() - 1
6152×
            })
6162×
        });
6173×
        let size = crate::render::bytes(a.size as u64);
6183×
        let (class, why) = match &a.build {
6192×
            Some(b) if b.receipt.is_some() && b.receipt_problem.is_none() => ("pass", "attested build"),
6200×
            Some(_) => ("warn", "built, not attested"),
6211×
            None => ("warn", "uploaded by hand"),
622
        };
6233×
        nodes.push(Node {
6243×
            col: asset_col,
6253×
            row: i,
6263×
            title: a.name.clone(),
6273×
            sub: format!("{size} · {why}"),
6283×
            class,
6293×
            tip: format!("{}: {size}, {why}", a.name),
6303×
        });
6313×
        if let Some(b) = build {
6322×
            edges.push((b, nodes.len() - 1, true));
6332×
        }
634
    }
63521×
    let x = |n: &Node| 1.0 + n.col as f64 * COL;
63630×
    let y = |n: &Node| 1.0 + n.row as f64 * (H + GAP);
6372×
    let width = 2.0 + asset_col as f64 * COL + W;
6382×
    let height = nodes.iter().map(y).fold(0.0, f64::max) + H + 2.0;
6392×
    let mut svg = format!(
640
        r#"<svg class="trust-graph" width="{width}" height="{height}" viewBox="0 0 {width} {height}" role="img" aria-label="Release trust graph">"#
641
    );
6426×
    for (a, b, solid) in &edges {
6436×
        let (x1, y1) = (x(&nodes[*a]) + W, y(&nodes[*a]) + H / 2.0);
6446×
        let (x2, y2) = (x(&nodes[*b]), y(&nodes[*b]) + H / 2.0);
6456×
        let mx = (x1 + x2) / 2.0;
6466×
        svg.push_str(&format!(
647
            r#"<path class="edge{}" d="M{x1},{y1} C{mx},{y1} {mx},{y2} {x2},{y2}"/>"#,
6486×
            if *solid { "" } else { " dashed" }
649
        ));
650
    }
6519×
    for n in &nodes {
6529×
        let (nx, ny) = (x(n), y(n));
653
        // The badge, in a circle at the node's right.
6549×
        let (cx, cy) = (nx + W - 16.0, ny + H / 2.0);
6559×
        let badge = match n.class {
656
            // A padlock: the shackle, then the body.
6579×
            "pass" => format!(
658
                r#"<path class="shackle" d="M{},{} v-3 a3.5,3.5 0 0 1 7,0 v3"/><rect class="lock" x="{}" y="{}" width="10" height="7.5" rx="1.5"/>"#,
6598×
                cx - 3.5,
6608×
                cy - 0.5,
6618×
                cx - 5.0,
6628×
                cy - 0.5
663
            ),
6641×
            "warn" => format!(r#"<text class="mark" x="{cx}" y="{}" text-anchor="middle">!</text>"#, cy + 4.0),
6650×
            _ => format!(r#"<text class="mark" x="{cx}" y="{}" text-anchor="middle">✘</text>"#, cy + 4.0),
666
        };
6679×
        svg.push_str(&format!(
6689×
            r#"<g class="node {}"><title>{}</title><rect x="{nx}" y="{ny}" width="{W}" height="{H}" rx="6"/><circle class="badge" cx="{cx}" cy="{cy}" r="10"/>{badge}<text x="{}" y="{}" class="t">{}</text><text x="{}" y="{}" class="s">{}</text></g>"#,
6699×
            n.class,
6709×
            esc(&n.tip),
6719×
            nx + 10.0,
6729×
            ny + 17.0,
6739×
            esc(&truncate(&n.title, 28)),
6749×
            nx + 10.0,
6759×
            ny + 31.0,
6769×
            esc(&truncate(&n.sub, 32)),
6779×
        ));
678
    }
6792×
    svg.push_str("</svg>");
6802×
    svg
6812×
}
682
68318×
fn truncate(s: &str, n: usize) -> String {
68418×
    if s.chars().count() <= n {
68517×
        s.to_string()
686
    } else {
6871×
        format!("{}…", s.chars().take(n - 1).collect::<String>())
688
    }
68918×
}
690
691
#[derive(Template)]
692
#[template(path = "release_trust.html")]
693
pub struct ReleaseTrustPage {
694
    shell: Shell,
695
    repo: RepoShell,
696
    t: ReleaseTrust,
697
    graph: String,
698
}
699
700
#[derive(Deserialize, Default)]
701
pub struct TrustQuery {
702
    #[serde(default)]
703
    format: Option<String>,
704
}
705
706
/// `GET /{owner}/{name}/releases/trust/{*tag}`: the page, or with
707
/// `?format=json` the report as a download.
7084×
pub async fn page(
7094×
    State(app): State<Shared>,
7104×
    viewer: Viewer,
7114×
    Path((owner, name, tag)): Path<(String, String, String)>,
7124×
    Query(q): Query<TrustQuery>,
7134×
) -> Result<Response, Error> {
7144×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/releases/trust/{tag}"), &viewer);
7154×
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
7163×
    let t =
7174×
        report(&app, &repo, &row, &tag).await.map_err(|m| Error::bad(shell.clone(), m))?.ok_or_else(|| Error::not_found(shell.clone()))?;
7183×
    if q.format.as_deref() == Some("json") {
7192×
        let file: String = tag.chars().map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') { c } else { '-' }).collect();
7201×
        return Ok((
7211×
            [
7221×
                (axum::http::header::CONTENT_TYPE, "application/json".to_string()),
7231×
                (axum::http::header::CONTENT_DISPOSITION, format!("attachment; filename=\"{}-{file}-trust.json\"", row.name)),
7241×
            ],
7251×
            serde_json::to_string_pretty(&t).unwrap_or_default(),
7261×
        )
7271×
            .into_response());
7282×
    }
7292×
    let graph = graph(&t);
7302×
    Ok(Page(ReleaseTrustPage { shell, repo: RepoShell::new(&app, &row, "releases").with_role(role), t, graph }).into_response())
7314×
}