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