OXYGENHUB
◆ v0.2.0 oxygenhub / crates / hub / src / web / release_trust.rs
24.2 KiB · Rust · last changed Runners: registration, Admin -> Runners, history, drain/disable/remove 1 month ago source blame history raw
df940d75 Andy McCall ↶ 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.
71
    pub runner_known: bool,
72
    /// The signed receipt, hex, for `oxy receipt verify`.
73
    pub signed_receipt: Option<String>,
74
}
75
76
#[derive(Clone, Debug, Serialize)]
77
pub struct AssetTrust {
78
    pub name: String,
79
    pub size: i64,
80
    pub blake3: String,
81
    pub url: String,
82
    pub build: Option<BuildTrust>,
83
}
84
85
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
86
#[serde(rename_all = "lowercase")]
87
pub enum Verdict {
88
    Pass,
89
    Warn,
90
    Fail,
91
}
92
93
#[derive(Clone, Debug, Serialize)]
94
pub struct Check {
95
    pub verdict: Verdict,
96
    pub title: String,
97
    pub detail: String,
98
}
99
100
impl Check {
101
    pub fn glyph(&self) -> &'static str {
102
        match self.verdict {
103
            Verdict::Pass => "✔",
104
            Verdict::Warn => "!",
105
            Verdict::Fail => "✘",
106
        }
107
    }
108
    pub fn class(&self) -> &'static str {
109
        match self.verdict {
110
            Verdict::Pass => "pass",
111
            Verdict::Warn => "warn",
112
            Verdict::Fail => "fail",
113
        }
114
    }
115
}
116
117
#[derive(Clone, Debug, Serialize)]
118
pub struct ReleaseTrust {
119
    pub repository: String,
120
    pub tag: String,
121
    pub title: String,
122
    pub release_url: String,
123
    pub heads: Vec<String>,
124
    pub tag_signer: Option<KeyInfo>,
125
    /// `valid`, `unsigned`, `invalid: …` or `revoked`.
126
    pub tag_signature: String,
127
    /// The previous release, which bounds the changes counted.
128
    pub since: Option<String>,
129
    pub changes: usize,
130
    pub authors: Vec<AuthorTrust>,
131
    pub assets: Vec<AssetTrust>,
132
    pub audit: Vec<Check>,
133
    pub generated_ms: i64,
134
}
135
136
impl ReleaseTrust {
137
    pub fn verdict_class(&self) -> &'static str {
138
        match self.verdict() {
139
            Verdict::Pass => "pass",
140
            Verdict::Warn => "warn",
141
            Verdict::Fail => "fail",
142
        }
143
    }
144
145
    pub fn verdict(&self) -> Verdict {
146
        if self.audit.iter().any(|c| c.verdict == Verdict::Fail) {
147
            Verdict::Fail
148
        } else if self.audit.iter().any(|c| c.verdict == Verdict::Warn) {
149
            Verdict::Warn
150
        } else {
151
            Verdict::Pass
152
        }
153
    }
154
}
155
156
/// What the repository says about the release: read in one go.
157
struct RepoFacts {
158
    heads: Vec<ChangeHash>,
159
    tag_signer: Option<KeyInfo>,
160
    tag_signature: String,
161
    authors: Vec<AuthorTrust>,
162
    changes: usize,
163
    /// Kept receipts by attestation: (runner, problem, revoked, signed hex).
164
    receipts: HashMap<String, (KeyInfo, Option<String>, bool, String)>,
165
}
166
167
fn key_info(r: &oxy_core::repo::Repo, key: &ObjectRef) -> KeyInfo {
168
    match r.store().get_identity(key) {
169
        Ok(i) => KeyInfo { key: key.to_hex(), name: i.name, email: i.email, account: None },
170
        Err(_) => KeyInfo { key: key.to_hex(), ..Default::default() },
171
    }
172
}
173
174
/// An asset's build: the matching artifact's job, with its run and job rows.
175
type Build = (fdb::Provenance, Option<fdb::Run>, Option<fdb::Job>);
176
177
/// Build the report for release `tag`, or `None` when there is no such
178
/// release or its tag is gone.
179
pub async fn report(app: &Shared, repo: &RepoName, row: &db::repos::RepoRow, tag: &str) -> Result<Option<ReleaseTrust>, String> {
180
    let releases = rdb::list(&app.db, row.id).await.map_err(|e| e.to_string())?;
181
    let Some(rel) = releases.iter().find(|r| r.tag == tag).cloned() else { return Ok(None) };
182
    // The previous release: the next older one in the list (newest first).
183
    let since = releases.iter().skip_while(|r| r.id != rel.id).nth(1).map(|r| r.tag.clone());
184
    let assets = rdb::assets(&app.db, rel.id).await.map_err(|e| e.to_string())?;
185
    let mut builds: Vec<Option<Build>> = Vec::new();
186
    for a in &assets {
187
        match fdb::provenance(&app.db, row.id, &a.hash).await.map_err(|e| e.to_string())? {
188
            Some(p) => {
189
                let run = fdb::run(&app.db, row.id, p.run_number).await.map_err(|e| e.to_string())?;
190
                let job = match &run {
191
                    Some(r) => fdb::jobs(&app.db, r.id).await.map_err(|e| e.to_string())?.into_iter().find(|j| j.name == p.job),
192
                    None => None,
193
                };
194
                builds.push(Some((p, run, job)));
195
            }
196
            None => builds.push(None),
197
        }
198
    }
199
    let attests: Vec<String> = builds.iter().flatten().map(|(p, _, _)| p.receipt_attest.clone()).filter(|a| !a.is_empty()).collect();
200
    let (tag_s, since_s) = (tag.to_string(), since.clone());
201
    let facts = app
202
        .repos
203
        .read(repo, move |r, cache| {
204
            let Some(heads) = r.tag_heads(&tag_s)? else { return Ok(None) };
205
            // The tag's attestation and whether its signature verifies.
206
            let (tag_signer, tag_signature) = match r.tag_attest(&tag_s)? {
207
                Some((ar, sa)) => match sa.sigs.first() {
208
                    Some(sig) => {
209
                        let who = key_info(r, &sig.key);
210
                        let verdict = match r.store().get_identity(&sig.key) {
211
                            Ok(id) => match verify(&id, sig, Domain::Attest, &ar) {
212
                                Ok(()) if r.is_revoked(&sig.key)? => "revoked".to_string(),
213
                                Ok(()) => "valid".to_string(),
214
                                Err(e) => format!("invalid: {e}"),
215
                            },
216
                            Err(_) => "invalid: the signer's identity is missing".to_string(),
217
                        };
218
                        (Some(who), verdict)
219
                    }
220
                    None => (None, "unsigned".to_string()),
221
                },
222
                None => (None, "unsigned".to_string()),
223
            };
224
            // The changes the release adds over the previous one, by author.
225
            let now = cache.load(r, &heads)?;
226
            let before = match &since_s {
227
                Some(p) => r.tag_heads(p)?.unwrap_or_default(),
228
                None => vec![],
229
            };
230
            let then = cache.load(r, &before)?;
231
            let mut by_author: BTreeMap<String, AuthorTrust> = BTreeMap::new();
232
            let mut changes = 0;
233
            for (h, meta) in now.state.changes() {
234
                if then.state.contains(h) {
235
                    continue;
236
                }
237
                changes += 1;
238
                let a = by_author
239
                    .entry(meta.author.to_hex())
240
                    .or_insert_with(|| AuthorTrust { who: key_info(r, &meta.author), ..Default::default() });
241
                if meta.author.is_zero() {
242
                    a.who.name = "(no author: imported or made before an identity)".into();
243
                }
244
                a.changes += 1;
245
                match r.authenticate_change(h)? {
246
                    SignatureStatus::Valid { revoked: true, .. } => a.revoked += 1,
247
                    SignatureStatus::Valid { sealed: true, .. } => a.sealed += 1,
248
                    SignatureStatus::Valid { .. } => a.signed += 1,
249
                    SignatureStatus::Unsigned => a.unsigned += 1,
250
                    SignatureStatus::Invalid(_) => a.invalid += 1,
251
                }
252
            }
253
            let mut authors: Vec<AuthorTrust> = by_author.into_values().collect();
254
            authors.sort_by(|a, b| b.changes.cmp(&a.changes).then(a.who.name.cmp(&b.who.name)));
255
            // Kept receipts, verified again from the store.
256
            let mut receipts = HashMap::new();
257
            for a in &attests {
258
                let Some(ar) = ObjectRef::from_hex(a) else { continue };
259
                if let Ok(i) = r.receipt(&ar) {
260
                    let runner = KeyInfo {
261
                        key: i.signed.runner.hash().to_hex(),
262
                        name: i.signed.runner.name.clone(),
263
                        email: i.signed.runner.email.clone(),
264
                        account: None,
265
                    };
266
                    receipts.insert(a.clone(), (runner, i.problem.clone(), i.revoked, hex::encode(i.signed.encode())));
267
                }
268
            }
269
            Ok(Some(RepoFacts { heads, tag_signer, tag_signature, authors, changes, receipts }))
270
        })
271
        .await
272
        .map_err(|e| e.to_string())?;
273
    let Some(mut facts) = facts else { return Ok(None) };
274
275
    // Link keys to accounts.
276
    let links: HashMap<String, String> = db::accounts::identity_accounts(&app.db).await.unwrap_or_default().into_iter().collect();
277
    if let Some(s) = facts.tag_signer.as_mut() {
278
        s.account = links.get(&s.key).cloned();
279
    }
280
    for a in &mut facts.authors {
281
        a.who.account = links.get(&a.who.key).cloned();
282
    }
b6b7e4cf Andy McCall ↶ 283
    let known_runner_keys: Vec<String> = db::runners::keys(&app.db).await.unwrap_or_default();
df940d75 Andy McCall ↶ 284
285
    let base = format!("{}/{}", app.config.base_url(), row.slug());
286
    let mut tag_heads: Vec<String> = facts.heads.iter().map(|h| h.to_hex()).collect();
287
    tag_heads.sort();
288
    let tag_state = tag_heads.join("+");
289
    let mut asset_rows = Vec::new();
290
    for (a, b) in assets.iter().zip(builds) {
291
        let build = b.map(|(p, run, job)| {
292
            let receipt_json: serde_json::Value =
293
                job.as_ref().and_then(|j| serde_json::from_str(&j.receipt).ok()).unwrap_or(serde_json::Value::Null);
294
            let sandbox: serde_json::Value =
295
                job.as_ref().and_then(|j| serde_json::from_str(&j.sandbox).ok()).unwrap_or(serde_json::Value::Null);
296
            let heads = run.as_ref().map(|r| r.heads.clone()).unwrap_or_default();
297
            let kept = facts.receipts.get(&p.receipt_attest).cloned();
298
            let s = |v: &serde_json::Value| v.as_str().unwrap_or("").to_string();
299
            BuildTrust {
300
                run: p.run_number,
301
                job: p.job.clone(),
302
                run_url: format!("{base}/flow/{}", p.run_number),
303
                same_state_as_tag: heads == tag_state,
304
                heads,
305
                pipeline_hash: s(&receipt_json["pipeline_hash"]),
306
                scope_tree_hash: s(&receipt_json["scope_tree_hash"]),
307
                environment: s(&receipt_json["environment"]),
308
                rootfs: s(&sandbox["rootfs"]),
309
                runtime: s(&sandbox["runtime"]),
310
                exit_code: job.as_ref().and_then(|j| j.exit_code),
311
                runner_known: kept.as_ref().is_some_and(|(k, ..)| known_runner_keys.contains(&k.key)),
312
                runner: kept.as_ref().map(|(k, ..)| k.clone()),
313
                receipt: kept.as_ref().map(|_| p.receipt_attest.clone()),
314
                receipt_url: kept.as_ref().map(|_| format!("{base}/receipts/{}", p.receipt_attest)),
315
                receipt_problem: kept.as_ref().and_then(|(_, pr, ..)| pr.clone()),
316
                runner_revoked: kept.as_ref().is_some_and(|(_, _, rv, _)| *rv),
317
                signed_receipt: kept.map(|(.., hex)| hex),
318
            }
319
        });
320
        asset_rows.push(AssetTrust {
321
            name: a.name.clone(),
322
            size: a.size,
323
            blake3: a.hash.clone(),
324
            url: format!("{base}/asset/{}/{}", a.id, a.name),
325
            build,
326
        });
327
    }
328
    let mut t = ReleaseTrust {
329
        repository: row.slug(),
330
        tag: tag.to_string(),
331
        title: rel.title.clone(),
332
        release_url: format!("{base}/releases/tag/{tag}"),
333
        heads: tag_heads,
334
        tag_signer: facts.tag_signer,
335
        tag_signature: facts.tag_signature,
336
        since,
337
        changes: facts.changes,
338
        authors: facts.authors,
339
        assets: asset_rows,
340
        audit: vec![],
341
        generated_ms: db::now_ms(),
342
    };
343
    t.audit = audit(&t);
344
    Ok(Some(t))
345
}
346
347
/// The audit: every claim the report makes, judged.
348
pub fn audit(t: &ReleaseTrust) -> Vec<Check> {
349
    let mut out = Vec::new();
350
    let check = |verdict, title: String, detail: String| Check { verdict, title, detail };
351
    let signer = t.tag_signer.as_ref().map(|k| if k.name.is_empty() { k.key[..12].to_string() } else { k.name.clone() });
352
    out.push(match t.tag_signature.as_str() {
353
        "valid" => check(Verdict::Pass, format!("Tag {} is signed", t.tag), format!("by {}", signer.unwrap_or_default())),
354
        "unsigned" => check(
355
            Verdict::Warn,
356
            format!("Tag {} is not signed", t.tag),
357
            "Create tags with a key (`oxy identity create`) so they carry a signed attestation.".into(),
358
        ),
359
        "revoked" => check(Verdict::Fail, format!("Tag {} is signed by a revoked key", t.tag), signer.unwrap_or_default()),
360
        other => check(Verdict::Fail, format!("Tag {}'s signature does not verify", t.tag), other.to_string()),
361
    });
362
    let sum = |f: fn(&AuthorTrust) -> usize| t.authors.iter().map(f).sum::<usize>();
363
    let (unsigned, invalid, revoked) = (sum(|a| a.unsigned), sum(|a| a.invalid), sum(|a| a.revoked));
364
    let scope = match &t.since {
365
        Some(p) => format!("since {p}"),
366
        None => "in the release".to_string(),
367
    };
368
    out.push(if invalid > 0 || revoked > 0 {
369
        check(
370
            Verdict::Fail,
371
            format!("{} of {} changes {scope} fail authentication", invalid + revoked, t.changes),
372
            format!("{invalid} with an invalid signature, {revoked} by revoked keys"),
373
        )
374
    } else if unsigned > 0 {
375
        check(
376
            Verdict::Warn,
377
            format!("{unsigned} of {} changes {scope} are unsigned", t.changes),
378
            "Changes are authenticated when their author signs or seals them on push (Oxygen spec §14.4).".into(),
379
        )
380
    } else {
381
        let what = match t.changes {
382
            1 => format!("The one change {scope} is authenticated"),
383
            n => format!("All {n} changes {scope} are authenticated"),
384
        };
385
        let by = match t.authors.len() {
386
            1 => "by one author".to_string(),
387
            n => format!("by {n} authors"),
388
        };
389
        check(Verdict::Pass, what, by)
390
    });
391
    if t.assets.is_empty() {
392
        out.push(check(Verdict::Warn, "The release has no assets".into(), "Nothing to trace to a build.".into()));
393
    }
394
    for a in &t.assets {
395
        match &a.build {
396
            None => out.push(check(
397
                Verdict::Warn,
398
                format!("{}: no build found", a.name),
399
                "No passed OxygenFlow job of this repository produced these bytes; it was uploaded by hand.".into(),
400
            )),
401
            Some(b) => {
402
                out.push(match (&b.receipt, &b.receipt_problem, b.runner_revoked, b.runner_known) {
403
                    (None, ..) => check(
404
                        Verdict::Warn,
405
                        format!("{}: built by run #{} {}, without a signed receipt", a.name, b.run, b.job),
406
                        "The runner did not sign its receipt, or the hub did not keep it.".into(),
407
                    ),
408
                    (Some(_), Some(p), ..) => check(Verdict::Fail, format!("{}: the signed receipt does not verify", a.name), p.clone()),
409
                    (Some(_), None, true, _) => {
410
                        check(Verdict::Fail, format!("{}: the receipt's runner key was revoked", a.name), String::new())
411
                    }
412
                    (Some(_), None, false, known) => check(
413
                        if known { Verdict::Pass } else { Verdict::Warn },
414
                        format!("{}: signed receipt from run #{} {}", a.name, b.run, b.job),
415
                        format!(
416
                            "signed by {}{}",
417
                            b.runner.as_ref().map(|k| k.name.as_str()).unwrap_or(""),
418
                            if known { ", a runner key of this hub" } else { "; that key is not one this hub's runners use" }
419
                        ),
420
                    ),
421
                });
422
                out.push(if b.same_state_as_tag {
423
                    check(
424
                        Verdict::Pass,
425
                        format!("{}: built from the tagged state", a.name),
426
                        t.heads.iter().map(|h| &h[..12]).collect::<Vec<_>>().join("+"),
427
                    )
428
                } else {
429
                    check(
430
                        Verdict::Warn,
431
                        format!("{}: built from another state than the tag's", a.name),
432
                        format!("run #{} built {}", b.run, b.heads.split('+').map(|h| &h[..12.min(h.len())]).collect::<Vec<_>>().join("+")),
433
                    )
434
                });
435
                let pinned = b.rootfs.len() == 64 && b.rootfs.bytes().all(|c| c.is_ascii_hexdigit()) && !b.environment.is_empty();
436
                out.push(if pinned {
437
                    check(Verdict::Pass, format!("{}: pinned environment", a.name), format!("{} rootfs {}", b.runtime, &b.rootfs[..12]))
438
                } else {
439
                    check(Verdict::Warn, format!("{}: environment not pinned by hash", a.name), b.rootfs.clone())
440
                });
441
            }
442
        }
443
    }
444
    out
445
}
446
447
/// The report as a graph: authors, the tagged state, the builds and the
448
/// assets, in four columns, as inline SVG.
449
pub fn graph(t: &ReleaseTrust) -> String {
450
    const W: f64 = 180.0;
451
    const H: f64 = 34.0;
452
    const GAP: f64 = 12.0;
453
    const COL: f64 = 230.0;
454
    let esc = crate::render::escape;
455
    let mut nodes: Vec<(f64, f64, String, String, &str)> = Vec::new();
456
    let mut edges: Vec<(usize, usize, bool)> = Vec::new();
457
    let y = |i: usize| 10.0 + i as f64 * (H + GAP);
458
    let authors: Vec<usize> = t
459
        .authors
460
        .iter()
461
        .take(12)
462
        .enumerate()
463
        .map(|(i, a)| {
464
            let name = if a.who.name.is_empty() { a.who.key[..12].to_string() } else { a.who.name.clone() };
465
            let ok = a.invalid + a.revoked == 0 && a.unsigned == 0;
466
            nodes.push((
467
                0.0,
468
                y(i),
469
                name,
470
                format!("{} changes", a.changes),
471
                if ok {
472
                    "pass"
473
                } else if a.invalid + a.revoked > 0 {
474
                    "fail"
475
                } else {
476
                    "warn"
477
                },
478
            ));
479
            nodes.len() - 1
480
        })
481
        .collect();
482
    let tag_class = match t.tag_signature.as_str() {
483
        "valid" => "pass",
484
        "unsigned" => "warn",
485
        _ => "fail",
486
    };
487
    nodes.push((COL, y(0), format!("tag {}", t.tag), format!("{} change{}", t.changes, if t.changes == 1 { "" } else { "s" }), tag_class));
488
    let tag = nodes.len() - 1;
489
    for a in authors {
490
        edges.push((a, tag, true));
491
    }
492
    let mut builds: HashMap<(i64, String), usize> = HashMap::new();
493
    for (i, a) in t.assets.iter().enumerate() {
494
        let build = a.build.as_ref().map(|b| {
495
            *builds.entry((b.run, b.job.clone())).or_insert_with(|| {
496
                let class = match (&b.receipt, &b.receipt_problem) {
497
                    (Some(_), None) => "pass",
498
                    (Some(_), Some(_)) => "fail",
499
                    _ => "warn",
500
                };
501
                let row = nodes.iter().filter(|n| n.0 == 2.0 * COL).count();
502
                nodes.push((
503
                    2.0 * COL,
504
                    y(row),
505
                    format!("run #{} {}", b.run, b.job),
506
                    b.runner.as_ref().map(|r| r.name.clone()).unwrap_or_else(|| "unsigned".into()),
507
                    class,
508
                ));
509
                edges.push((tag, nodes.len() - 1, b.same_state_as_tag));
510
                nodes.len() - 1
511
            })
512
        });
513
        let class = if a.build.is_some() { "pass" } else { "warn" };
514
        nodes.push((3.0 * COL, y(i), a.name.clone(), crate::render::bytes(a.size as u64), class));
515
        if let Some(b) = build {
516
            edges.push((b, nodes.len() - 1, true));
517
        }
518
    }
519
    let rows = nodes.iter().map(|n| n.1).fold(0.0, f64::max) + H + 10.0;
520
    let mut svg =
521
        format!(r#"<svg class="trust-graph" viewBox="0 0 {} {rows}" role="img" aria-label="Release trust graph">"#, 3.0 * COL + W + 2.0);
522
    for (a, b, solid) in &edges {
523
        let (x1, y1) = (nodes[*a].0 + W, nodes[*a].1 + H / 2.0);
524
        let (x2, y2) = (nodes[*b].0, nodes[*b].1 + H / 2.0);
525
        let mx = (x1 + x2) / 2.0;
526
        svg.push_str(&format!(
527
            r#"<path class="edge{}" d="M{x1},{y1} C{mx},{y1} {mx},{y2} {x2},{y2}"/>"#,
528
            if *solid { "" } else { " dashed" }
529
        ));
530
    }
531
    for (x, y, title, sub, class) in &nodes {
532
        svg.push_str(&format!(
533
            r#"<g class="node {class}"><rect x="{x}" y="{y}" width="{W}" height="{H}" rx="4"/><text x="{}" y="{}" class="t">{}</text><text x="{}" y="{}" class="s">{}</text></g>"#,
534
            x + 8.0,
535
            y + 14.0,
536
            esc(&truncate(title, 26)),
537
            x + 8.0,
538
            y + 27.0,
539
            esc(&truncate(sub, 30)),
540
        ));
541
    }
542
    svg.push_str("</svg>");
543
    svg
544
}
545
546
fn truncate(s: &str, n: usize) -> String {
547
    if s.chars().count() <= n {
548
        s.to_string()
549
    } else {
550
        format!("{}…", s.chars().take(n - 1).collect::<String>())
551
    }
552
}
553
554
#[derive(Template)]
555
#[template(path = "release_trust.html")]
556
pub struct ReleaseTrustPage {
557
    shell: Shell,
558
    repo: RepoShell,
559
    t: ReleaseTrust,
560
    graph: String,
561
}
562
563
#[derive(Deserialize, Default)]
564
pub struct TrustQuery {
565
    #[serde(default)]
566
    format: Option<String>,
567
}
568
569
/// `GET /{owner}/{name}/releases/trust/{*tag}`: the page, or with
570
/// `?format=json` the report as a download.
571
pub async fn page(
572
    State(app): State<Shared>,
573
    viewer: Viewer,
574
    Path((owner, name, tag)): Path<(String, String, String)>,
575
    Query(q): Query<TrustQuery>,
576
) -> Result<Response, Error> {
577
    let shell = Shell::new(&app, &format!("/{owner}/{name}/releases/trust/{tag}"), &viewer);
578
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
579
    let t =
580
        report(&app, &repo, &row, &tag).await.map_err(|m| Error::bad(shell.clone(), m))?.ok_or_else(|| Error::not_found(shell.clone()))?;
581
    if q.format.as_deref() == Some("json") {
582
        let file: String = tag.chars().map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') { c } else { '-' }).collect();
583
        return Ok((
584
            [
585
                (axum::http::header::CONTENT_TYPE, "application/json".to_string()),
586
                (axum::http::header::CONTENT_DISPOSITION, format!("attachment; filename=\"{}-{file}-trust.json\"", row.name)),
587
            ],
588
            serde_json::to_string_pretty(&t).unwrap_or_default(),
589
        )
590
            .into_response());
591
    }
592
    let graph = graph(&t);
593
    Ok(Page(ReleaseTrustPage { shell, repo: RepoShell::new(&app, &row, "releases").with_role(role), t, graph }).into_response())
594
}