| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 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 |
|
| 23 |
#[derive(Clone, Debug, Default, Serialize)] |
| 24 |
pub struct KeyInfo { |
| 25 |
pub key: String, |
| 26 |
pub name: String, |
| 27 |
pub email: String, |
| 28 |
|
| 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 |
|
| 37 |
pub signed: usize, |
| 38 |
|
| 39 |
pub sealed: usize, |
| 40 |
pub unsigned: usize, |
| 41 |
|
| 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 |
|
| 52 |
pub heads: String, |
| 53 |
|
| 54 |
pub same_state_as_tag: bool, |
| 55 |
pub pipeline_hash: String, |
| 56 |
pub scope_tree_hash: String, |
| 57 |
|
| 58 |
|
| 59 |
pub environment: String, |
| 60 |
pub rootfs: String, |
| 61 |
pub runtime: String, |
| 62 |
pub exit_code: Option<i64>, |
| 63 |
pub runner: Option<KeyInfo>, |
| 64 |
|
| 65 |
pub receipt: Option<String>, |
| 66 |
pub receipt_url: Option<String>, |
| 67 |
|
| 68 |
pub receipt_problem: Option<String>, |
| 69 |
pub runner_revoked: bool, |
| 70 |
|
| 71 |
pub runner_known: bool, |
| 72 |
|
| 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 |
|
| 118 |
pub struct TrustBadge { |
| 119 |
pub class: &'static str, |
| 120 |
pub text: String, |
| 121 |
} |
| 122 |
|
| 123 |
#[derive(Clone, Debug, Serialize)] |
| 124 |
pub struct ReleaseTrust { |
| 125 |
pub repository: String, |
| 126 |
pub tag: String, |
| 127 |
pub title: String, |
| 128 |
pub release_url: String, |
| 129 |
pub heads: Vec<String>, |
| 130 |
pub tag_signer: Option<KeyInfo>, |
| 131 |
|
| 132 |
pub tag_signature: String, |
| 133 |
|
| 134 |
pub since: Option<String>, |
| 135 |
pub changes: usize, |
| 136 |
pub authors: Vec<AuthorTrust>, |
| 137 |
pub assets: Vec<AssetTrust>, |
| 138 |
pub audit: Vec<Check>, |
| 139 |
pub generated_ms: i64, |
| 140 |
} |
| 141 |
|
| 142 |
impl ReleaseTrust { |
| 143 |
pub fn verdict_class(&self) -> &'static str { |
| 144 |
match self.verdict() { |
| 145 |
Verdict::Pass => "pass", |
| 146 |
Verdict::Warn => "warn", |
| 147 |
Verdict::Fail => "fail", |
| 148 |
} |
| 149 |
} |
| 150 |
|
| 151 |
|
| 152 |
|
| 153 |
|
| 154 |
pub fn summary(&self) -> Vec<TrustBadge> { |
| 155 |
let tag = match self.tag_signature.as_str() { |
| 156 |
"valid" => ("pass", format!("Tag signed by {}", self.tag_signer.as_ref().map(|k| k.name.as_str()).unwrap_or("its signer"))), |
| 157 |
"unsigned" => ("warn", "Tag unsigned".to_string()), |
| 158 |
other => ("fail", format!("Tag signature {other}")), |
| 159 |
}; |
| 160 |
let authenticated: usize = self.authors.iter().map(|a| a.signed + a.sealed).sum(); |
| 161 |
let bad: usize = self.authors.iter().map(|a| a.invalid + a.revoked).sum(); |
| 162 |
let changes = ( |
| 163 |
if bad > 0 { |
| 164 |
"fail" |
| 165 |
} else if authenticated < self.changes { |
| 166 |
"warn" |
| 167 |
} else { |
| 168 |
"pass" |
| 169 |
}, |
| 170 |
format!("{authenticated} of {} changes signed or sealed", self.changes), |
| 171 |
); |
| 172 |
let attested = |
| 173 |
self.assets.iter().filter(|a| a.build.as_ref().is_some_and(|b| b.receipt.is_some() && b.receipt_problem.is_none())).count(); |
| 174 |
let assets = ( |
| 175 |
if attested == self.assets.len() { "pass" } else { "warn" }, |
| 176 |
if self.assets.is_empty() { |
| 177 |
"No assets".to_string() |
| 178 |
} else { |
| 179 |
format!("{attested} of {} assets built and attested by OxygenFlow", self.assets.len()) |
| 180 |
}, |
| 181 |
); |
| 182 |
[tag, changes, assets].into_iter().map(|(class, text)| TrustBadge { class, text }).collect() |
| 183 |
} |
| 184 |
|
| 185 |
pub fn verdict(&self) -> Verdict { |
| 186 |
if self.audit.iter().any(|c| c.verdict == Verdict::Fail) { |
| 187 |
Verdict::Fail |
| 188 |
} else if self.audit.iter().any(|c| c.verdict == Verdict::Warn) { |
| 189 |
Verdict::Warn |
| 190 |
} else { |
| 191 |
Verdict::Pass |
| 192 |
} |
| 193 |
} |
| 194 |
} |
| 195 |
|
| 196 |
|
| 197 |
struct RepoFacts { |
| 198 |
heads: Vec<ChangeHash>, |
| 199 |
tag_signer: Option<KeyInfo>, |
| 200 |
tag_signature: String, |
| 201 |
authors: Vec<AuthorTrust>, |
| 202 |
changes: usize, |
| 203 |
|
| 204 |
receipts: HashMap<String, (KeyInfo, Option<String>, bool, String)>, |
| 205 |
} |
| 206 |
|
| 207 |
fn key_info(r: &oxy_core::repo::Repo, key: &ObjectRef) -> KeyInfo { |
| 208 |
match r.store().get_identity(key) { |
| 209 |
Ok(i) => KeyInfo { key: key.to_hex(), name: i.name, email: i.email, account: None }, |
| 210 |
Err(_) => KeyInfo { key: key.to_hex(), ..Default::default() }, |
| 211 |
} |
| 212 |
} |
| 213 |
|
| 214 |
|
| 215 |
type Build = (fdb::Provenance, Option<fdb::Run>, Option<fdb::Job>); |
| 216 |
|
| 217 |
|
| 218 |
|
| 219 |
pub async fn report(app: &Shared, repo: &RepoName, row: &db::repos::RepoRow, tag: &str) -> Result<Option<ReleaseTrust>, String> { |
| 220 |
let releases = rdb::list(&app.db, row.id).await.map_err(|e| e.to_string())?; |
| 221 |
let Some(rel) = releases.iter().find(|r| r.tag == tag).cloned() else { return Ok(None) }; |
| 222 |
|
| 223 |
let since = releases.iter().skip_while(|r| r.id != rel.id).nth(1).map(|r| r.tag.clone()); |
| 224 |
let assets = rdb::assets(&app.db, rel.id).await.map_err(|e| e.to_string())?; |
| 225 |
let mut builds: Vec<Option<Build>> = Vec::new(); |
| 226 |
for a in &assets { |
| 227 |
match fdb::provenance(&app.db, row.id, &a.hash).await.map_err(|e| e.to_string())? { |
| 228 |
Some(p) => { |
| 229 |
let run = fdb::run(&app.db, row.id, p.run_number).await.map_err(|e| e.to_string())?; |
| 230 |
let job = match &run { |
| 231 |
Some(r) => fdb::jobs(&app.db, r.id).await.map_err(|e| e.to_string())?.into_iter().find(|j| j.name == p.job), |
| 232 |
None => None, |
| 233 |
}; |
| 234 |
builds.push(Some((p, run, job))); |
| 235 |
} |
| 236 |
None => builds.push(None), |
| 237 |
} |
| 238 |
} |
| 239 |
let attests: Vec<String> = builds.iter().flatten().map(|(p, _, _)| p.receipt_attest.clone()).filter(|a| !a.is_empty()).collect(); |
| 240 |
let (tag_s, since_s) = (tag.to_string(), since.clone()); |
| 241 |
let facts = app |
| 242 |
.repos |
| 243 |
.read(repo, move |r, cache| { |
| 244 |
let Some(heads) = r.tag_heads(&tag_s)? else { return Ok(None) }; |
| 245 |
|
| 246 |
let (tag_signer, tag_signature) = match r.tag_attest(&tag_s)? { |
| 247 |
Some((ar, sa)) => match sa.sigs.first() { |
| 248 |
Some(sig) => { |
| 249 |
let who = key_info(r, &sig.key); |
| 250 |
let verdict = match r.store().get_identity(&sig.key) { |
| 251 |
Ok(id) => match verify(&id, sig, Domain::Attest, &ar) { |
| 252 |
Ok(()) if r.is_revoked(&sig.key)? => "revoked".to_string(), |
| 253 |
Ok(()) => "valid".to_string(), |
| 254 |
Err(e) => format!("invalid: {e}"), |
| 255 |
}, |
| 256 |
Err(_) => "invalid: the signer's identity is missing".to_string(), |
| 257 |
}; |
| 258 |
(Some(who), verdict) |
| 259 |
} |
| 260 |
None => (None, "unsigned".to_string()), |
| 261 |
}, |
| 262 |
None => (None, "unsigned".to_string()), |
| 263 |
}; |
| 264 |
|
| 265 |
let now = cache.load(r, &heads)?; |
| 266 |
let before = match &since_s { |
| 267 |
Some(p) => r.tag_heads(p)?.unwrap_or_default(), |
| 268 |
None => vec![], |
| 269 |
}; |
| 270 |
let then = cache.load(r, &before)?; |
| 271 |
let mut by_author: BTreeMap<String, AuthorTrust> = BTreeMap::new(); |
| 272 |
let mut changes = 0; |
| 273 |
for (h, meta) in now.state.changes() { |
| 274 |
if then.state.contains(h) { |
| 275 |
continue; |
| 276 |
} |
| 277 |
changes += 1; |
| 278 |
let a = by_author |
| 279 |
.entry(meta.author.to_hex()) |
| 280 |
.or_insert_with(|| AuthorTrust { who: key_info(r, &meta.author), ..Default::default() }); |
| 281 |
if meta.author.is_zero() { |
| 282 |
a.who.name = "(no author: imported or made before an identity)".into(); |
| 283 |
} |
| 284 |
a.changes += 1; |
| 285 |
match r.authenticate_change(h)? { |
| 286 |
SignatureStatus::Valid { revoked: true, .. } => a.revoked += 1, |
| 287 |
SignatureStatus::Valid { sealed: true, .. } => a.sealed += 1, |
| 288 |
SignatureStatus::Valid { .. } => a.signed += 1, |
| 289 |
SignatureStatus::Unsigned => a.unsigned += 1, |
| 290 |
SignatureStatus::Invalid(_) => a.invalid += 1, |
| 291 |
} |
| 292 |
} |
| 293 |
let mut authors: Vec<AuthorTrust> = by_author.into_values().collect(); |
| 294 |
authors.sort_by(|a, b| b.changes.cmp(&a.changes).then(a.who.name.cmp(&b.who.name))); |
| 295 |
|
| 296 |
let mut receipts = HashMap::new(); |
| 297 |
for a in &attests { |
| 298 |
let Some(ar) = ObjectRef::from_hex(a) else { continue }; |
| 299 |
if let Ok(i) = r.receipt(&ar) { |
| 300 |
let runner = KeyInfo { |
| 301 |
key: i.signed.runner.hash().to_hex(), |
| 302 |
name: i.signed.runner.name.clone(), |
| 303 |
email: i.signed.runner.email.clone(), |
| 304 |
account: None, |
| 305 |
}; |
| 306 |
receipts.insert(a.clone(), (runner, i.problem.clone(), i.revoked, hex::encode(i.signed.encode()))); |
| 307 |
} |
| 308 |
} |
| 309 |
Ok(Some(RepoFacts { heads, tag_signer, tag_signature, authors, changes, receipts })) |
| 310 |
}) |
| 311 |
.await |
| 312 |
.map_err(|e| e.to_string())?; |
| 313 |
let Some(mut facts) = facts else { return Ok(None) }; |
| 314 |
|
| 315 |
|
| 316 |
let links: HashMap<String, String> = db::accounts::identity_accounts(&app.db).await.unwrap_or_default().into_iter().collect(); |
| 317 |
if let Some(s) = facts.tag_signer.as_mut() { |
| 318 |
s.account = links.get(&s.key).cloned(); |
| 319 |
} |
| 320 |
for a in &mut facts.authors { |
| 321 |
a.who.account = links.get(&a.who.key).cloned(); |
| 322 |
} |
| 323 |
let known_runner_keys: Vec<String> = db::runners::keys(&app.db).await.unwrap_or_default(); |
| 324 |
|
| 325 |
let base = format!("{}/{}", app.config.base_url(), row.slug()); |
| 326 |
let mut tag_heads: Vec<String> = facts.heads.iter().map(|h| h.to_hex()).collect(); |
| 327 |
tag_heads.sort(); |
| 328 |
let tag_state = tag_heads.join("+"); |
| 329 |
let mut asset_rows = Vec::new(); |
| 330 |
for (a, b) in assets.iter().zip(builds) { |
| 331 |
let build = b.map(|(p, run, job)| { |
| 332 |
let receipt_json: serde_json::Value = |
| 333 |
job.as_ref().and_then(|j| serde_json::from_str(&j.receipt).ok()).unwrap_or(serde_json::Value::Null); |
| 334 |
let sandbox: serde_json::Value = |
| 335 |
job.as_ref().and_then(|j| serde_json::from_str(&j.sandbox).ok()).unwrap_or(serde_json::Value::Null); |
| 336 |
let heads = run.as_ref().map(|r| r.heads.clone()).unwrap_or_default(); |
| 337 |
let kept = facts.receipts.get(&p.receipt_attest).cloned(); |
| 338 |
let s = |v: &serde_json::Value| v.as_str().unwrap_or("").to_string(); |
| 339 |
BuildTrust { |
| 340 |
run: p.run_number, |
| 341 |
job: p.job.clone(), |
| 342 |
run_url: format!("{base}/flow/{}", p.run_number), |
| 343 |
same_state_as_tag: heads == tag_state, |
| 344 |
heads, |
| 345 |
pipeline_hash: s(&receipt_json["pipeline_hash"]), |
| 346 |
scope_tree_hash: s(&receipt_json["scope_tree_hash"]), |
| 347 |
environment: s(&receipt_json["environment"]), |
| 348 |
rootfs: s(&sandbox["rootfs"]), |
| 349 |
runtime: s(&sandbox["runtime"]), |
| 350 |
exit_code: job.as_ref().and_then(|j| j.exit_code), |
| 351 |
runner_known: kept.as_ref().is_some_and(|(k, ..)| known_runner_keys.contains(&k.key)), |
| 352 |
runner: kept.as_ref().map(|(k, ..)| k.clone()), |
| 353 |
receipt: kept.as_ref().map(|_| p.receipt_attest.clone()), |
| 354 |
receipt_url: kept.as_ref().map(|_| format!("{base}/receipts/{}", p.receipt_attest)), |
| 355 |
receipt_problem: kept.as_ref().and_then(|(_, pr, ..)| pr.clone()), |
| 356 |
runner_revoked: kept.as_ref().is_some_and(|(_, _, rv, _)| *rv), |
| 357 |
signed_receipt: kept.map(|(.., hex)| hex), |
| 358 |
} |
| 359 |
}); |
| 360 |
asset_rows.push(AssetTrust { |
| 361 |
name: a.name.clone(), |
| 362 |
size: a.size, |
| 363 |
blake3: a.hash.clone(), |
| 364 |
url: format!("{base}/asset/{}/{}", a.id, a.name), |
| 365 |
build, |
| 366 |
}); |
| 367 |
} |
| 368 |
let mut t = ReleaseTrust { |
| 369 |
repository: row.slug(), |
| 370 |
tag: tag.to_string(), |
| 371 |
title: rel.title.clone(), |
| 372 |
release_url: format!("{base}/releases/tag/{tag}"), |
| 373 |
heads: tag_heads, |
| 374 |
tag_signer: facts.tag_signer, |
| 375 |
tag_signature: facts.tag_signature, |
| 376 |
since, |
| 377 |
changes: facts.changes, |
| 378 |
authors: facts.authors, |
| 379 |
assets: asset_rows, |
| 380 |
audit: vec![], |
| 381 |
generated_ms: db::now_ms(), |
| 382 |
}; |
| 383 |
t.audit = audit(&t); |
| 384 |
Ok(Some(t)) |
| 385 |
} |
| 386 |
|
| 387 |
|
| 388 |
pub fn audit(t: &ReleaseTrust) -> Vec<Check> { |
| 389 |
let mut out = Vec::new(); |
| 390 |
let check = |verdict, title: String, detail: String| Check { verdict, title, detail }; |
| 391 |
let signer = t.tag_signer.as_ref().map(|k| if k.name.is_empty() { k.key[..12].to_string() } else { k.name.clone() }); |
| 392 |
out.push(match t.tag_signature.as_str() { |
| 393 |
"valid" => check(Verdict::Pass, format!("Tag {} is signed", t.tag), format!("by {}", signer.unwrap_or_default())), |
| 394 |
"unsigned" => check( |
| 395 |
Verdict::Warn, |
| 396 |
format!("Tag {} is not signed", t.tag), |
| 397 |
"Create tags with a key (`oxy identity create`) so they carry a signed attestation.".into(), |
| 398 |
), |
| 399 |
"revoked" => check(Verdict::Fail, format!("Tag {} is signed by a revoked key", t.tag), signer.unwrap_or_default()), |
| 400 |
other => check(Verdict::Fail, format!("Tag {}'s signature does not verify", t.tag), other.to_string()), |
| 401 |
}); |
| 402 |
let sum = |f: fn(&AuthorTrust) -> usize| t.authors.iter().map(f).sum::<usize>(); |
| 403 |
let (unsigned, invalid, revoked) = (sum(|a| a.unsigned), sum(|a| a.invalid), sum(|a| a.revoked)); |
| 404 |
let scope = match &t.since { |
| 405 |
Some(p) => format!("since {p}"), |
| 406 |
None => "in the release".to_string(), |
| 407 |
}; |
| 408 |
out.push(if invalid > 0 || revoked > 0 { |
| 409 |
check( |
| 410 |
Verdict::Fail, |
| 411 |
format!("{} of {} changes {scope} fail authentication", invalid + revoked, t.changes), |
| 412 |
format!("{invalid} with an invalid signature, {revoked} by revoked keys"), |
| 413 |
) |
| 414 |
} else if unsigned > 0 { |
| 415 |
check( |
| 416 |
Verdict::Warn, |
| 417 |
format!("{unsigned} of {} changes {scope} are unsigned", t.changes), |
| 418 |
"Changes are authenticated when their author signs or seals them on push (Oxygen spec §14.4).".into(), |
| 419 |
) |
| 420 |
} else { |
| 421 |
let what = match t.changes { |
| 422 |
1 => format!("The one change {scope} is authenticated"), |
| 423 |
n => format!("All {n} changes {scope} are authenticated"), |
| 424 |
}; |
| 425 |
let by = match t.authors.len() { |
| 426 |
1 => "by one author".to_string(), |
| 427 |
n => format!("by {n} authors"), |
| 428 |
}; |
| 429 |
check(Verdict::Pass, what, by) |
| 430 |
}); |
| 431 |
if t.assets.is_empty() { |
| 432 |
out.push(check(Verdict::Warn, "The release has no assets".into(), "Nothing to trace to a build.".into())); |
| 433 |
} |
| 434 |
for a in &t.assets { |
| 435 |
match &a.build { |
| 436 |
None => out.push(check( |
| 437 |
Verdict::Warn, |
| 438 |
format!("{}: no build found", a.name), |
| 439 |
"No passed OxygenFlow job of this repository produced these bytes; it was uploaded by hand.".into(), |
| 440 |
)), |
| 441 |
Some(b) => { |
| 442 |
out.push(match (&b.receipt, &b.receipt_problem, b.runner_revoked, b.runner_known) { |
| 443 |
(None, ..) => check( |
| 444 |
Verdict::Warn, |
| 445 |
format!("{}: built by run #{} {}, without a signed receipt", a.name, b.run, b.job), |
| 446 |
"The runner did not sign its receipt, or the hub did not keep it.".into(), |
| 447 |
), |
| 448 |
(Some(_), Some(p), ..) => check(Verdict::Fail, format!("{}: the signed receipt does not verify", a.name), p.clone()), |
| 449 |
(Some(_), None, true, _) => { |
| 450 |
check(Verdict::Fail, format!("{}: the receipt's runner key was revoked", a.name), String::new()) |
| 451 |
} |
| 452 |
(Some(_), None, false, known) => check( |
| 453 |
if known { Verdict::Pass } else { Verdict::Warn }, |
| 454 |
format!("{}: signed receipt from run #{} {}", a.name, b.run, b.job), |
| 455 |
format!( |
| 456 |
"signed by {}{}", |
| 457 |
b.runner.as_ref().map(|k| k.name.as_str()).unwrap_or(""), |
| 458 |
if known { ", a runner key of this hub" } else { "; that key is not one this hub's runners use" } |
| 459 |
), |
| 460 |
), |
| 461 |
}); |
| 462 |
out.push(if b.same_state_as_tag { |
| 463 |
check( |
| 464 |
Verdict::Pass, |
| 465 |
format!("{}: built from the tagged state", a.name), |
| 466 |
t.heads.iter().map(|h| &h[..12]).collect::<Vec<_>>().join("+"), |
| 467 |
) |
| 468 |
} else { |
| 469 |
check( |
| 470 |
Verdict::Warn, |
| 471 |
format!("{}: built from another state than the tag's", a.name), |
| 472 |
format!("run #{} built {}", b.run, b.heads.split('+').map(|h| &h[..12.min(h.len())]).collect::<Vec<_>>().join("+")), |
| 473 |
) |
| 474 |
}); |
| 475 |
let pinned = b.rootfs.len() == 64 && b.rootfs.bytes().all(|c| c.is_ascii_hexdigit()) && !b.environment.is_empty(); |
| 476 |
out.push(if pinned { |
| 477 |
check(Verdict::Pass, format!("{}: pinned environment", a.name), format!("{} rootfs {}", b.runtime, &b.rootfs[..12])) |
| 478 |
} else { |
| 479 |
check(Verdict::Warn, format!("{}: environment not pinned by hash", a.name), b.rootfs.clone()) |
| 480 |
}); |
| 481 |
} |
| 482 |
} |
| 483 |
} |
| 484 |
out |
| 485 |
} |
| 486 |
|
| 487 |
|
| 488 |
|
| 489 |
|
| 490 |
|
| 491 |
|
| 492 |
pub fn graph(t: &ReleaseTrust) -> String { |
| 493 |
const W: f64 = 220.0; |
| 494 |
const H: f64 = 40.0; |
| 495 |
const GAP: f64 = 10.0; |
| 496 |
const COL: f64 = W + 48.0; |
| 497 |
struct Node { |
| 498 |
col: usize, |
| 499 |
row: usize, |
| 500 |
title: String, |
| 501 |
sub: String, |
| 502 |
class: &'static str, |
| 503 |
tip: String, |
| 504 |
} |
| 505 |
let esc = crate::render::escape; |
| 506 |
let mut nodes: Vec<Node> = Vec::new(); |
| 507 |
let mut edges: Vec<(usize, usize, bool)> = Vec::new(); |
| 508 |
let any_build = t.assets.iter().any(|a| a.build.is_some()); |
| 509 |
let asset_col = if any_build { 3 } else { 2 }; |
| 510 |
let authors: Vec<usize> = t |
| 511 |
.authors |
| 512 |
.iter() |
| 513 |
.take(12) |
| 514 |
.enumerate() |
| 515 |
.map(|(i, a)| { |
| 516 |
let name = if a.who.name.is_empty() { a.who.key[..12].to_string() } else { a.who.name.clone() }; |
| 517 |
let (class, why) = if a.invalid + a.revoked > 0 { |
| 518 |
("fail", format!("{} invalid or revoked", a.invalid + a.revoked)) |
| 519 |
} else if a.unsigned > 0 { |
| 520 |
("warn", format!("{} of {} unsigned", a.unsigned, a.changes)) |
| 521 |
} else { |
| 522 |
("pass", "all signed".to_string()) |
| 523 |
}; |
| 524 |
let linked = if a.who.account.is_some() { "key linked to an account" } else { "key not linked to an account" }; |
| 525 |
nodes.push(Node { |
| 526 |
col: 0, |
| 527 |
row: i, |
| 528 |
title: name.clone(), |
| 529 |
sub: format!("{} change{} · {why}", a.changes, if a.changes == 1 { "" } else { "s" }), |
| 530 |
class, |
| 531 |
tip: format!("{name}: {} changes, {} signed, {} sealed, {} unsigned; {linked}", a.changes, a.signed, a.sealed, a.unsigned), |
| 532 |
}); |
| 533 |
nodes.len() - 1 |
| 534 |
}) |
| 535 |
.collect(); |
| 536 |
let signer = t.tag_signer.as_ref().map(|k| k.name.clone()).unwrap_or_default(); |
| 537 |
let (tag_class, tag_sub) = match t.tag_signature.as_str() { |
| 538 |
"valid" => ("pass", format!("signed by {signer}")), |
| 539 |
"unsigned" => ("warn", "unsigned".to_string()), |
| 540 |
other => ("fail", other.to_string()), |
| 541 |
}; |
| 542 |
nodes.push(Node { |
| 543 |
col: 1, |
| 544 |
row: 0, |
| 545 |
title: format!("tag {}", t.tag), |
| 546 |
sub: tag_sub.clone(), |
| 547 |
class: tag_class, |
| 548 |
tip: format!("tag {}: {} changes; {tag_sub}", t.tag, t.changes), |
| 549 |
}); |
| 550 |
let tag = nodes.len() - 1; |
| 551 |
for a in authors { |
| 552 |
edges.push((a, tag, true)); |
| 553 |
} |
| 554 |
let mut builds: HashMap<(i64, String), usize> = HashMap::new(); |
| 555 |
let mut build_rows = 0; |
| 556 |
for (i, a) in t.assets.iter().enumerate() { |
| 557 |
let build = a.build.as_ref().map(|b| { |
| 558 |
*builds.entry((b.run, b.job.clone())).or_insert_with(|| { |
| 559 |
let runner = b.runner.as_ref().map(|r| r.name.clone()).unwrap_or_default(); |
| 560 |
let (class, why) = match (&b.receipt, &b.receipt_problem) { |
| 561 |
(Some(_), None) => ("pass", format!("receipt verified · {runner}")), |
| 562 |
(Some(_), Some(p)) => ("fail", p.clone()), |
| 563 |
_ => ("warn", "no signed receipt".to_string()), |
| 564 |
}; |
| 565 |
nodes.push(Node { |
| 566 |
col: 2, |
| 567 |
row: build_rows, |
| 568 |
title: format!("run #{} {}", b.run, b.job), |
| 569 |
sub: why.clone(), |
| 570 |
class, |
| 571 |
tip: format!( |
| 572 |
"run #{} job {}: {why}{}", |
| 573 |
b.run, |
| 574 |
b.job, |
| 575 |
if b.same_state_as_tag { ", from the tagged state" } else { ", from another state than the tag's" } |
| 576 |
), |
| 577 |
}); |
| 578 |
build_rows += 1; |
| 579 |
edges.push((tag, nodes.len() - 1, b.same_state_as_tag)); |
| 580 |
nodes.len() - 1 |
| 581 |
}) |
| 582 |
}); |
| 583 |
let size = crate::render::bytes(a.size as u64); |
| 584 |
let (class, why) = match &a.build { |
| 585 |
Some(b) if b.receipt.is_some() && b.receipt_problem.is_none() => ("pass", "attested build"), |
| 586 |
Some(_) => ("warn", "built, not attested"), |
| 587 |
None => ("warn", "uploaded by hand"), |
| 588 |
}; |
| 589 |
nodes.push(Node { |
| 590 |
col: asset_col, |
| 591 |
row: i, |
| 592 |
title: a.name.clone(), |
| 593 |
sub: format!("{size} · {why}"), |
| 594 |
class, |
| 595 |
tip: format!("{}: {size}, {why}", a.name), |
| 596 |
}); |
| 597 |
if let Some(b) = build { |
| 598 |
edges.push((b, nodes.len() - 1, true)); |
| 599 |
} |
| 600 |
} |
| 601 |
let x = |n: &Node| 1.0 + n.col as f64 * COL; |
| 602 |
let y = |n: &Node| 1.0 + n.row as f64 * (H + GAP); |
| 603 |
let width = 2.0 + asset_col as f64 * COL + W; |
| 604 |
let height = nodes.iter().map(y).fold(0.0, f64::max) + H + 2.0; |
| 605 |
let mut svg = format!( |
| 606 |
r#"<svg class="trust-graph" width="{width}" height="{height}" viewBox="0 0 {width} {height}" role="img" aria-label="Release trust graph">"# |
| 607 |
); |
| 608 |
for (a, b, solid) in &edges { |
| 609 |
let (x1, y1) = (x(&nodes[*a]) + W, y(&nodes[*a]) + H / 2.0); |
| 610 |
let (x2, y2) = (x(&nodes[*b]), y(&nodes[*b]) + H / 2.0); |
| 611 |
let mx = (x1 + x2) / 2.0; |
| 612 |
svg.push_str(&format!( |
| 613 |
r#"<path class="edge{}" d="M{x1},{y1} C{mx},{y1} {mx},{y2} {x2},{y2}"/>"#, |
| 614 |
if *solid { "" } else { " dashed" } |
| 615 |
)); |
| 616 |
} |
| 617 |
for n in &nodes { |
| 618 |
let (nx, ny) = (x(n), y(n)); |
| 619 |
|
| 620 |
let (cx, cy) = (nx + W - 16.0, ny + H / 2.0); |
| 621 |
let badge = match n.class { |
| 622 |
|
| 623 |
"pass" => format!( |
| 624 |
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"/>"#, |
| 625 |
cx - 3.5, |
| 626 |
cy - 0.5, |
| 627 |
cx - 5.0, |
| 628 |
cy - 0.5 |
| 629 |
), |
| 630 |
"warn" => format!(r#"<text class="mark" x="{cx}" y="{}" text-anchor="middle">!</text>"#, cy + 4.0), |
| 631 |
_ => format!(r#"<text class="mark" x="{cx}" y="{}" text-anchor="middle">✘</text>"#, cy + 4.0), |
| 632 |
}; |
| 633 |
svg.push_str(&format!( |
| 634 |
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>"#, |
| 635 |
n.class, |
| 636 |
esc(&n.tip), |
| 637 |
nx + 10.0, |
| 638 |
ny + 17.0, |
| 639 |
esc(&truncate(&n.title, 28)), |
| 640 |
nx + 10.0, |
| 641 |
ny + 31.0, |
| 642 |
esc(&truncate(&n.sub, 32)), |
| 643 |
)); |
| 644 |
} |
| 645 |
svg.push_str("</svg>"); |
| 646 |
svg |
| 647 |
} |
| 648 |
|
| 649 |
fn truncate(s: &str, n: usize) -> String { |
| 650 |
if s.chars().count() <= n { |
| 651 |
s.to_string() |
| 652 |
} else { |
| 653 |
format!("{}…", s.chars().take(n - 1).collect::<String>()) |
| 654 |
} |
| 655 |
} |
| 656 |
|
| 657 |
#[derive(Template)] |
| 658 |
#[template(path = "release_trust.html")] |
| 659 |
pub struct ReleaseTrustPage { |
| 660 |
shell: Shell, |
| 661 |
repo: RepoShell, |
| 662 |
t: ReleaseTrust, |
| 663 |
graph: String, |
| 664 |
} |
| 665 |
|
| 666 |
#[derive(Deserialize, Default)] |
| 667 |
pub struct TrustQuery { |
| 668 |
#[serde(default)] |
| 669 |
format: Option<String>, |
| 670 |
} |
| 671 |
|
| 672 |
|
| 673 |
|
| 674 |
pub async fn page( |
| 675 |
State(app): State<Shared>, |
| 676 |
viewer: Viewer, |
| 677 |
Path((owner, name, tag)): Path<(String, String, String)>, |
| 678 |
Query(q): Query<TrustQuery>, |
| 679 |
) -> Result<Response, Error> { |
| 680 |
let shell = Shell::new(&app, &format!("/{owner}/{name}/releases/trust/{tag}"), &viewer); |
| 681 |
let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
| 682 |
let t = |
| 683 |
report(&app, &repo, &row, &tag).await.map_err(|m| Error::bad(shell.clone(), m))?.ok_or_else(|| Error::not_found(shell.clone()))?; |
| 684 |
if q.format.as_deref() == Some("json") { |
| 685 |
let file: String = tag.chars().map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') { c } else { '-' }).collect(); |
| 686 |
return Ok(( |
| 687 |
[ |
| 688 |
(axum::http::header::CONTENT_TYPE, "application/json".to_string()), |
| 689 |
(axum::http::header::CONTENT_DISPOSITION, format!("attachment; filename=\"{}-{file}-trust.json\"", row.name)), |
| 690 |
], |
| 691 |
serde_json::to_string_pretty(&t).unwrap_or_default(), |
| 692 |
) |
| 693 |
.into_response()); |
| 694 |
} |
| 695 |
let graph = graph(&t); |
| 696 |
Ok(Page(ReleaseTrustPage { shell, repo: RepoShell::new(&app, &row, "releases").with_role(role), t, graph }).into_response()) |
| 697 |
} |