OXYGENHUB

Trust and audit for each release df940d75cd3d

/{owner}/{name}/releases/trust/{tag} reports on a release: the tag's
signer and signature; the authors of the changes since the previous
release, with how many are signed, sealed, unsigned, invalid or signed
by revoked keys; and for each asset, the run, job, runner key, pinned
environment, inputs (state, pipeline, scope) and signed receipt, verified
again. It includes a graph (authors -> tag -> jobs -> assets) and a
pass/warn/fail audit of every claim. ?format=json and the API give the
report with the signed receipts for oxy receipt verify.

closes #42
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-29 10:35 UTC
Signature
✔ signed by Andy McCall
Hash
df940d75cd3d00ff196cc0c5b8fcf4fcf1e3db4b628738009abe4031bb7655c9
Depends on
10708ab8f54d 3f4b0ea6eabf 42fa95540468 6ad368c5958e 93be9ce4653e a3c8288b0141 b64e2537d702 d23464c0ac60
Depended on by
93f445fa50d8 244724133fed d2c1d6a7d12e 97fc47fee9cf d29070faef66 edcef27575a7 20c36ed92b6b bf5101d394c0 b6b7e4cf6b24
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
In tags
v0.1.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
10 atoms, 2 tree operations, 8 files

Browse the tree at this change

added crates/hub/src/web/release_trust.rs +595 −0
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 +
    }
283 +
    let known_runner_keys: Vec<String> =
284 +
        sqlx::query_scalar("SELECT key FROM flow_runner_keys").fetch_all(&app.db).await.unwrap_or_default();
285 +
286 +
    let base = format!("{}/{}", app.config.base_url(), row.slug());
287 +
    let mut tag_heads: Vec<String> = facts.heads.iter().map(|h| h.to_hex()).collect();
288 +
    tag_heads.sort();
289 +
    let tag_state = tag_heads.join("+");
290 +
    let mut asset_rows = Vec::new();
291 +
    for (a, b) in assets.iter().zip(builds) {
292 +
        let build = b.map(|(p, run, job)| {
293 +
            let receipt_json: serde_json::Value =
294 +
                job.as_ref().and_then(|j| serde_json::from_str(&j.receipt).ok()).unwrap_or(serde_json::Value::Null);
295 +
            let sandbox: serde_json::Value =
296 +
                job.as_ref().and_then(|j| serde_json::from_str(&j.sandbox).ok()).unwrap_or(serde_json::Value::Null);
297 +
            let heads = run.as_ref().map(|r| r.heads.clone()).unwrap_or_default();
298 +
            let kept = facts.receipts.get(&p.receipt_attest).cloned();
299 +
            let s = |v: &serde_json::Value| v.as_str().unwrap_or("").to_string();
300 +
            BuildTrust {
301 +
                run: p.run_number,
302 +
                job: p.job.clone(),
303 +
                run_url: format!("{base}/flow/{}", p.run_number),
304 +
                same_state_as_tag: heads == tag_state,
305 +
                heads,
306 +
                pipeline_hash: s(&receipt_json["pipeline_hash"]),
307 +
                scope_tree_hash: s(&receipt_json["scope_tree_hash"]),
308 +
                environment: s(&receipt_json["environment"]),
309 +
                rootfs: s(&sandbox["rootfs"]),
310 +
                runtime: s(&sandbox["runtime"]),
311 +
                exit_code: job.as_ref().and_then(|j| j.exit_code),
312 +
                runner_known: kept.as_ref().is_some_and(|(k, ..)| known_runner_keys.contains(&k.key)),
313 +
                runner: kept.as_ref().map(|(k, ..)| k.clone()),
314 +
                receipt: kept.as_ref().map(|_| p.receipt_attest.clone()),
315 +
                receipt_url: kept.as_ref().map(|_| format!("{base}/receipts/{}", p.receipt_attest)),
316 +
                receipt_problem: kept.as_ref().and_then(|(_, pr, ..)| pr.clone()),
317 +
                runner_revoked: kept.as_ref().is_some_and(|(_, _, rv, _)| *rv),
318 +
                signed_receipt: kept.map(|(.., hex)| hex),
319 +
            }
320 +
        });
321 +
        asset_rows.push(AssetTrust {
322 +
            name: a.name.clone(),
323 +
            size: a.size,
324 +
            blake3: a.hash.clone(),
325 +
            url: format!("{base}/asset/{}/{}", a.id, a.name),
326 +
            build,
327 +
        });
328 +
    }
329 +
    let mut t = ReleaseTrust {
330 +
        repository: row.slug(),
331 +
        tag: tag.to_string(),
332 +
        title: rel.title.clone(),
333 +
        release_url: format!("{base}/releases/tag/{tag}"),
334 +
        heads: tag_heads,
335 +
        tag_signer: facts.tag_signer,
336 +
        tag_signature: facts.tag_signature,
337 +
        since,
338 +
        changes: facts.changes,
339 +
        authors: facts.authors,
340 +
        assets: asset_rows,
341 +
        audit: vec![],
342 +
        generated_ms: db::now_ms(),
343 +
    };
344 +
    t.audit = audit(&t);
345 +
    Ok(Some(t))
346 +
}
347 +
348 +
/// The audit: every claim the report makes, judged.
349 +
pub fn audit(t: &ReleaseTrust) -> Vec<Check> {
350 +
    let mut out = Vec::new();
351 +
    let check = |verdict, title: String, detail: String| Check { verdict, title, detail };
352 +
    let signer = t.tag_signer.as_ref().map(|k| if k.name.is_empty() { k.key[..12].to_string() } else { k.name.clone() });
353 +
    out.push(match t.tag_signature.as_str() {
354 +
        "valid" => check(Verdict::Pass, format!("Tag {} is signed", t.tag), format!("by {}", signer.unwrap_or_default())),
355 +
        "unsigned" => check(
356 +
            Verdict::Warn,
357 +
            format!("Tag {} is not signed", t.tag),
358 +
            "Create tags with a key (`oxy identity create`) so they carry a signed attestation.".into(),
359 +
        ),
360 +
        "revoked" => check(Verdict::Fail, format!("Tag {} is signed by a revoked key", t.tag), signer.unwrap_or_default()),
361 +
        other => check(Verdict::Fail, format!("Tag {}'s signature does not verify", t.tag), other.to_string()),
362 +
    });
363 +
    let sum = |f: fn(&AuthorTrust) -> usize| t.authors.iter().map(f).sum::<usize>();
364 +
    let (unsigned, invalid, revoked) = (sum(|a| a.unsigned), sum(|a| a.invalid), sum(|a| a.revoked));
365 +
    let scope = match &t.since {
366 +
        Some(p) => format!("since {p}"),
367 +
        None => "in the release".to_string(),
368 +
    };
369 +
    out.push(if invalid > 0 || revoked > 0 {
370 +
        check(
371 +
            Verdict::Fail,
372 +
            format!("{} of {} changes {scope} fail authentication", invalid + revoked, t.changes),
373 +
            format!("{invalid} with an invalid signature, {revoked} by revoked keys"),
374 +
        )
375 +
    } else if unsigned > 0 {
376 +
        check(
377 +
            Verdict::Warn,
378 +
            format!("{unsigned} of {} changes {scope} are unsigned", t.changes),
379 +
            "Changes are authenticated when their author signs or seals them on push (Oxygen spec §14.4).".into(),
380 +
        )
381 +
    } else {
382 +
        let what = match t.changes {
383 +
            1 => format!("The one change {scope} is authenticated"),
384 +
            n => format!("All {n} changes {scope} are authenticated"),
385 +
        };
386 +
        let by = match t.authors.len() {
387 +
            1 => "by one author".to_string(),
388 +
            n => format!("by {n} authors"),
389 +
        };
390 +
        check(Verdict::Pass, what, by)
391 +
    });
392 +
    if t.assets.is_empty() {
393 +
        out.push(check(Verdict::Warn, "The release has no assets".into(), "Nothing to trace to a build.".into()));
394 +
    }
395 +
    for a in &t.assets {
396 +
        match &a.build {
397 +
            None => out.push(check(
398 +
                Verdict::Warn,
399 +
                format!("{}: no build found", a.name),
400 +
                "No passed OxygenFlow job of this repository produced these bytes; it was uploaded by hand.".into(),
401 +
            )),
402 +
            Some(b) => {
403 +
                out.push(match (&b.receipt, &b.receipt_problem, b.runner_revoked, b.runner_known) {
404 +
                    (None, ..) => check(
405 +
                        Verdict::Warn,
406 +
                        format!("{}: built by run #{} {}, without a signed receipt", a.name, b.run, b.job),
407 +
                        "The runner did not sign its receipt, or the hub did not keep it.".into(),
408 +
                    ),
409 +
                    (Some(_), Some(p), ..) => check(Verdict::Fail, format!("{}: the signed receipt does not verify", a.name), p.clone()),
410 +
                    (Some(_), None, true, _) => {
411 +
                        check(Verdict::Fail, format!("{}: the receipt's runner key was revoked", a.name), String::new())
412 +
                    }
413 +
                    (Some(_), None, false, known) => check(
414 +
                        if known { Verdict::Pass } else { Verdict::Warn },
415 +
                        format!("{}: signed receipt from run #{} {}", a.name, b.run, b.job),
416 +
                        format!(
417 +
                            "signed by {}{}",
418 +
                            b.runner.as_ref().map(|k| k.name.as_str()).unwrap_or(""),
419 +
                            if known { ", a runner key of this hub" } else { "; that key is not one this hub's runners use" }
420 +
                        ),
421 +
                    ),
422 +
                });
423 +
                out.push(if b.same_state_as_tag {
424 +
                    check(
425 +
                        Verdict::Pass,
426 +
                        format!("{}: built from the tagged state", a.name),
427 +
                        t.heads.iter().map(|h| &h[..12]).collect::<Vec<_>>().join("+"),
428 +
                    )
429 +
                } else {
430 +
                    check(
431 +
                        Verdict::Warn,
432 +
                        format!("{}: built from another state than the tag's", a.name),
433 +
                        format!("run #{} built {}", b.run, b.heads.split('+').map(|h| &h[..12.min(h.len())]).collect::<Vec<_>>().join("+")),
434 +
                    )
435 +
                });
436 +
                let pinned = b.rootfs.len() == 64 && b.rootfs.bytes().all(|c| c.is_ascii_hexdigit()) && !b.environment.is_empty();
437 +
                out.push(if pinned {
438 +
                    check(Verdict::Pass, format!("{}: pinned environment", a.name), format!("{} rootfs {}", b.runtime, &b.rootfs[..12]))
439 +
                } else {
440 +
                    check(Verdict::Warn, format!("{}: environment not pinned by hash", a.name), b.rootfs.clone())
441 +
                });
442 +
            }
443 +
        }
444 +
    }
445 +
    out
446 +
}
447 +
448 +
/// The report as a graph: authors, the tagged state, the builds and the
449 +
/// assets, in four columns, as inline SVG.
450 +
pub fn graph(t: &ReleaseTrust) -> String {
451 +
    const W: f64 = 180.0;
452 +
    const H: f64 = 34.0;
453 +
    const GAP: f64 = 12.0;
454 +
    const COL: f64 = 230.0;
455 +
    let esc = crate::render::escape;
456 +
    let mut nodes: Vec<(f64, f64, String, String, &str)> = Vec::new();
457 +
    let mut edges: Vec<(usize, usize, bool)> = Vec::new();
458 +
    let y = |i: usize| 10.0 + i as f64 * (H + GAP);
459 +
    let authors: Vec<usize> = t
460 +
        .authors
461 +
        .iter()
462 +
        .take(12)
463 +
        .enumerate()
464 +
        .map(|(i, a)| {
465 +
            let name = if a.who.name.is_empty() { a.who.key[..12].to_string() } else { a.who.name.clone() };
466 +
            let ok = a.invalid + a.revoked == 0 && a.unsigned == 0;
467 +
            nodes.push((
468 +
                0.0,
469 +
                y(i),
470 +
                name,
471 +
                format!("{} changes", a.changes),
472 +
                if ok {
473 +
                    "pass"
474 +
                } else if a.invalid + a.revoked > 0 {
475 +
                    "fail"
476 +
                } else {
477 +
                    "warn"
478 +
                },
479 +
            ));
480 +
            nodes.len() - 1
481 +
        })
482 +
        .collect();
483 +
    let tag_class = match t.tag_signature.as_str() {
484 +
        "valid" => "pass",
485 +
        "unsigned" => "warn",
486 +
        _ => "fail",
487 +
    };
488 +
    nodes.push((COL, y(0), format!("tag {}", t.tag), format!("{} change{}", t.changes, if t.changes == 1 { "" } else { "s" }), tag_class));
489 +
    let tag = nodes.len() - 1;
490 +
    for a in authors {
491 +
        edges.push((a, tag, true));
492 +
    }
493 +
    let mut builds: HashMap<(i64, String), usize> = HashMap::new();
494 +
    for (i, a) in t.assets.iter().enumerate() {
495 +
        let build = a.build.as_ref().map(|b| {
496 +
            *builds.entry((b.run, b.job.clone())).or_insert_with(|| {
497 +
                let class = match (&b.receipt, &b.receipt_problem) {
498 +
                    (Some(_), None) => "pass",
499 +
                    (Some(_), Some(_)) => "fail",
500 +
                    _ => "warn",
501 +
                };
502 +
                let row = nodes.iter().filter(|n| n.0 == 2.0 * COL).count();
503 +
                nodes.push((
504 +
                    2.0 * COL,
505 +
                    y(row),
506 +
                    format!("run #{} {}", b.run, b.job),
507 +
                    b.runner.as_ref().map(|r| r.name.clone()).unwrap_or_else(|| "unsigned".into()),
508 +
                    class,
509 +
                ));
510 +
                edges.push((tag, nodes.len() - 1, b.same_state_as_tag));
511 +
                nodes.len() - 1
512 +
            })
513 +
        });
514 +
        let class = if a.build.is_some() { "pass" } else { "warn" };
515 +
        nodes.push((3.0 * COL, y(i), a.name.clone(), crate::render::bytes(a.size as u64), class));
516 +
        if let Some(b) = build {
517 +
            edges.push((b, nodes.len() - 1, true));
518 +
        }
519 +
    }
520 +
    let rows = nodes.iter().map(|n| n.1).fold(0.0, f64::max) + H + 10.0;
521 +
    let mut svg =
522 +
        format!(r#"<svg class="trust-graph" viewBox="0 0 {} {rows}" role="img" aria-label="Release trust graph">"#, 3.0 * COL + W + 2.0);
523 +
    for (a, b, solid) in &edges {
524 +
        let (x1, y1) = (nodes[*a].0 + W, nodes[*a].1 + H / 2.0);
525 +
        let (x2, y2) = (nodes[*b].0, nodes[*b].1 + H / 2.0);
526 +
        let mx = (x1 + x2) / 2.0;
527 +
        svg.push_str(&format!(
528 +
            r#"<path class="edge{}" d="M{x1},{y1} C{mx},{y1} {mx},{y2} {x2},{y2}"/>"#,
529 +
            if *solid { "" } else { " dashed" }
530 +
        ));
531 +
    }
532 +
    for (x, y, title, sub, class) in &nodes {
533 +
        svg.push_str(&format!(
534 +
            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>"#,
535 +
            x + 8.0,
536 +
            y + 14.0,
537 +
            esc(&truncate(title, 26)),
538 +
            x + 8.0,
539 +
            y + 27.0,
540 +
            esc(&truncate(sub, 30)),
541 +
        ));
542 +
    }
543 +
    svg.push_str("</svg>");
544 +
    svg
545 +
}
546 +
547 +
fn truncate(s: &str, n: usize) -> String {
548 +
    if s.chars().count() <= n {
549 +
        s.to_string()
550 +
    } else {
551 +
        format!("{}…", s.chars().take(n - 1).collect::<String>())
552 +
    }
553 +
}
554 +
555 +
#[derive(Template)]
556 +
#[template(path = "release_trust.html")]
557 +
pub struct ReleaseTrustPage {
558 +
    shell: Shell,
559 +
    repo: RepoShell,
560 +
    t: ReleaseTrust,
561 +
    graph: String,
562 +
}
563 +
564 +
#[derive(Deserialize, Default)]
565 +
pub struct TrustQuery {
566 +
    #[serde(default)]
567 +
    format: Option<String>,
568 +
}
569 +
570 +
/// `GET /{owner}/{name}/releases/trust/{*tag}`: the page, or with
571 +
/// `?format=json` the report as a download.
572 +
pub async fn page(
573 +
    State(app): State<Shared>,
574 +
    viewer: Viewer,
575 +
    Path((owner, name, tag)): Path<(String, String, String)>,
576 +
    Query(q): Query<TrustQuery>,
577 +
) -> Result<Response, Error> {
578 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/releases/trust/{tag}"), &viewer);
579 +
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
580 +
    let t =
581 +
        report(&app, &repo, &row, &tag).await.map_err(|m| Error::bad(shell.clone(), m))?.ok_or_else(|| Error::not_found(shell.clone()))?;
582 +
    if q.format.as_deref() == Some("json") {
583 +
        let file: String = tag.chars().map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') { c } else { '-' }).collect();
584 +
        return Ok((
585 +
            [
586 +
                (axum::http::header::CONTENT_TYPE, "application/json".to_string()),
587 +
                (axum::http::header::CONTENT_DISPOSITION, format!("attachment; filename=\"{}-{file}-trust.json\"", row.name)),
588 +
            ],
589 +
            serde_json::to_string_pretty(&t).unwrap_or_default(),
590 +
        )
591 +
            .into_response());
592 +
    }
593 +
    let graph = graph(&t);
594 +
    Ok(Page(ReleaseTrustPage { shell, repo: RepoShell::new(&app, &row, "releases").with_role(role), t, graph }).into_response())
595 +
}
added templates/release_trust.html +57 −0
1 +
{% extends "base.html" %}
2 +
{% import "repo_tabs.html" as m %}
3 +
{% block title %}Trust · {{ t.tag }} · {{ repo.owner }}/{{ repo.name }}{% endblock %}
4 +
{% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %}
5 +
{% block tabs %}{% call m::tabs(repo, "releases") %}{% endblock %}
6 +
{% block mainclass %} class="wide"{% endblock %}
7 +
{% block content %}
8 +
<div class="head"><h1>Trust <small>release <a href="{{ repo.url }}/releases/tag/{{ t.tag }}">{{ t.tag }}</a>{% if !t.title.is_empty() %} · {{ t.title }}{% endif %}</small></h1>
9 +
<a class="go small" href="?format=json" download>Export JSON</a></div>
10 +
<p class="small mute">Who wrote what this release holds, how it is authenticated, what built each asset and from what, with every claim audited below.{% if let Some(p) = t.since %} Changes are counted since the previous release, {{ p }}.{% endif %} The export carries the signed receipts: check each with <code>oxy receipt verify</code>.</p>
11 +
12 +
<section class="panel audit audit-{{ t.verdict_class() }}">
13 +
  <h2>Audit <span class="small mute">{{ t.audit.len()|plural("check", "checks") }}</span></h2>
14 +
  <ul class="plain checks">{% for c in t.audit %}<li class="{{ c.class() }}"><span class="glyph">{{ c.glyph() }}</span> <strong>{{ c.title }}</strong>{% if !c.detail.is_empty() %} <span class="small mute">{{ c.detail }}</span>{% endif %}</li>{% endfor %}</ul>
15 +
</section>
16 +
17 +
<section class="panel">
18 +
  <h2>Graph</h2>
19 +
  <p class="small mute">Authors → the tagged state → the jobs that built the assets → the assets. A dashed line is a build from another state than the tag's.</p>
20 +
  <div class="trust-graph-wrap">{{ graph|safe }}</div>
21 +
</section>
22 +
23 +
<section class="panel">
24 +
  <h2>Tag <span class="lbl lbl-tag">{{ t.tag }}</span></h2>
25 +
  <dl class="facts">
26 +
    <dt>Signature</dt><dd>{% if t.tag_signature == "valid" %}<span class="sig sig-valid">✔</span> valid{% else if t.tag_signature == "unsigned" %}<span class="sig sig-unsigned">○</span> unsigned{% else %}<span class="sig sig-invalid">✘</span> {{ t.tag_signature }}{% endif %}{% if let Some(k) = t.tag_signer %} · {% if let Some(a) = k.account %}<a href="/{{ a }}">{{ k.name }}</a>{% else %}{{ k.name }}{% endif %} <code title="{{ k.key }}">{{ k.key|short }}</code>{% endif %}</dd>
27 +
    <dt>State</dt><dd>{% for h in t.heads %}<a class="chip" href="{{ repo.url }}/change/{{ h }}">{{ h|short }}</a> {% endfor %}</dd>
28 +
  </dl>
29 +
</section>
30 +
31 +
<section class="panel">
32 +
  <h2>Authors <span class="small mute">{{ t.changes|plural("change", "changes") }}</span></h2>
33 +
  <div class="table"><table>
34 +
  <tr><th>Author</th><th>Key</th><th>Changes</th><th>Signed</th><th>Sealed</th><th>Unsigned</th><th>Problems</th></tr>
35 +
  {% for a in t.authors %}<tr><td>{% if let Some(acc) = a.who.account %}<a href="/{{ acc }}">{{ a.who.name }}</a>{% else %}{{ a.who.name }}{% endif %}</td><td><code title="{{ a.who.key }}">{{ a.who.key|short }}</code></td><td>{{ a.changes }}</td><td>{{ a.signed }}</td><td>{{ a.sealed }}</td><td>{% if a.unsigned > 0 %}<span class="warn">{{ a.unsigned }}</span>{% else %}0{% endif %}</td><td>{% if a.invalid + a.revoked > 0 %}<span class="bad">{{ a.invalid }} invalid, {{ a.revoked }} revoked</span>{% else %}none{% endif %}</td></tr>{% endfor %}
36 +
  </table></div>
37 +
</section>
38 +
39 +
<section class="panel">
40 +
  <h2>Assets</h2>
41 +
  {% if t.assets.is_empty() %}<p class="mute">This release has no assets.</p>{% endif %}
42 +
  {% for a in t.assets %}
43 +
  <h3><a href="{{ a.url }}">{{ a.name }}</a> <span class="small mute">{{ a.size|bytes }} · blake3 <code title="{{ a.blake3 }}">{{ a.blake3|short }}</code></span></h3>
44 +
  {% if let Some(b) = a.build %}
45 +
  <dl class="facts">
46 +
    <dt>Built by</dt><dd><a href="{{ b.run_url }}">run #{{ b.run }}</a> job <strong>{{ b.job }}</strong>{% if let Some(c) = b.exit_code %}, exit {{ c }}{% endif %}</dd>
47 +
    <dt>Runner</dt><dd>{% if let Some(r) = b.runner %}{{ r.name }} <code title="{{ r.key }}">{{ r.key|short }}</code>{% if b.runner_known %} <span class="tag">runner key of this hub</span>{% endif %}{% if b.runner_revoked %} <span class="tag bad">revoked</span>{% endif %}{% else %}<span class="mute">no signed receipt</span>{% endif %}</dd>
48 +
    <dt>Receipt</dt><dd>{% if let Some(u) = b.receipt_url %}<a href="{{ u }}">signed receipt</a>{% if let Some(p) = b.receipt_problem %} <span class="bad">{{ p }}</span>{% else %} <span class="sig sig-valid">✔</span> verifies{% endif %}{% else %}<span class="mute">none kept</span>{% endif %}</dd>
49 +
    <dt>Environment</dt><dd>{{ b.runtime }} · rootfs <code title="{{ b.rootfs }}">{{ b.rootfs|short }}</code> · environment <code title="{{ b.environment }}">{{ b.environment|short }}</code></dd>
50 +
    <dt>Inputs</dt><dd>state {% for h in b.heads.split('+') %}<a class="chip" href="{{ repo.url }}/change/{{ h }}">{{ h|short }}</a> {% endfor %}{% if b.same_state_as_tag %}<span class="tag">the tagged state</span>{% else %}<span class="tag warn">not the tagged state</span>{% endif %} · pipeline <code title="{{ b.pipeline_hash }}">{{ b.pipeline_hash|short }}</code> · scope <code title="{{ b.scope_tree_hash }}">{{ b.scope_tree_hash|short }}</code></dd>
51 +
  </dl>
52 +
  {% else %}
53 +
  <p class="small warn">No passed OxygenFlow job of this repository produced these bytes: uploaded by hand.</p>
54 +
  {% endif %}
55 +
  {% endfor %}
56 +
</section>
57 +
{% endblock %}
modified crates/hub/src/api/mod.rs +16 −0
…
54 unchanged lines
55 55
        .route("/repos/{owner}/{name}/blame/{r}/{*path}", get(blame))
56 56
        .route("/repos/{owner}/{name}/conflicts", get(conflicts))
57 57
        .route("/repos/{owner}/{name}/trust", get(trust))
58 +
        .route("/repos/{owner}/{name}/releases/trust/{*tag}", get(release_trust))
58 59
        .route("/repos/{owner}/{name}/pushes", get(pushes))
59 60
        .route("/repos/{owner}/{name}/proposals", get(proposals).post(create_proposal))
60 61
        .route("/repos/{owner}/{name}/proposals/{number}", get(proposal))
…
540 unchanged lines
601 602
            })
602 603
            .collect(),
603 604
    ))
605 +
}
606 +
607 +
/// `GET /repos/{o}/{n}/releases/trust/{tag}`: a release's trust report
608 +
/// (hub spec §9.1), as the page's JSON export.
609 +
async fn release_trust(
610 +
    State(app): State<Shared>,
611 +
    headers: HeaderMap,
612 +
    Path((owner, name, tag)): Path<(String, String, String)>,
613 +
) -> Result<Json<crate::web::release_trust::ReleaseTrust>, ApiError> {
614 +
    let c = caller(&app, &headers).await?;
615 +
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
616 +
    match crate::web::release_trust::report(&app, &repo, &row, &tag).await.map_err(|m| ApiError(StatusCode::INTERNAL_SERVER_ERROR, m))? {
617 +
        Some(t) => Ok(Json(t)),
618 +
        None => Err(ApiError(StatusCode::NOT_FOUND, format!("no release {tag}"))),
619 +
    }
604 620
}
605 621
606 622
async fn trust(
…
1046 unchanged lines
modified crates/hub/src/web/mod.rs +2 −0
…
18 unchanged lines
19 19
pub mod proposals;
20 20
mod recovery;
21 21
mod refs;
22 +
pub mod release_trust;
22 23
pub mod releases;
23 24
mod repo;
24 25
mod repo_settings;
…
131 unchanged lines
156 157
        .route("/{owner}/{name}/releases", get(releases::list))
157 158
        .route("/{owner}/{name}/releases/new", get(releases::new_form).post(releases::create))
158 159
        .route("/{owner}/{name}/releases/tag/{*tag}", get(releases::show))
160 +
        .route("/{owner}/{name}/releases/trust/{*tag}", get(release_trust::page))
159 161
        .route("/{owner}/{name}/releases/edit/{id}", post(releases::edit))
160 162
        .route(
161 163
            "/{owner}/{name}/releases/upload/{id}",
…
428 unchanged lines
modified crates/hub/tests/flow.rs +92 −0
…
892 unchanged lines
893 893
    );
894 894
    assert!(asset["built_by"]["receipt"].is_null(), "that receipt was not signed");
895 895
}
896 +
897 +
/// A release's trust report (hub spec §9.1): a signed tag, an asset built
898 +
/// by a job with a signed receipt from the tagged state, and one uploaded
899 +
/// by hand; the page, its JSON export and the API.
900 +
#[tokio::test(flavor = "multi_thread")]
901 +
async fn a_release_is_audited_from_authors_to_receipts() {
902 +
    use oxy_core::hash::{hash_object, ChangeHash, ObjectType};
903 +
    use oxy_core::identity::SecretKey;
904 +
    use oxy_core::objects::StateObject;
905 +
    use oxy_core::receipt::SignedReceipt;
906 +
    let h = hub().await;
907 +
    create(&h.app, "andy", "demo").await;
908 +
    let url = format!("{}/andy/demo", h.base);
909 +
    let work = tempfile::tempdir().unwrap();
910 +
    let mut c = client(&url, work.path());
911 +
    c.create_identity("Andy", "[email protected]", oxy_core::repo::IdentityScope::Repo).unwrap();
912 +
    write(
913 +
        work.path(),
914 +
        ".flow/pipeline.kdl",
915 +
        &format!(
916 +
            "schema-version 1\nsandbox \"alpine\" {{\n    rootfs \"cas://blob/{ROOTFS}\"\n}}\njob \"build\" {{\n    sandbox \"alpine\"\n    exec \"make\"\n    output {{\n        artifact \"tool\" {{\n            path \"/src/out/tool\"\n        }}\n    }}\n}}\n"
917 +
        ),
918 +
    );
919 +
    c.snapshot("test").unwrap();
920 +
    c.commit("Build a tool", &[]).unwrap();
921 +
    let heads = c.bookmark_heads().unwrap();
922 +
    c.tag_create("v1", &heads, "first").unwrap();
923 +
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
924 +
925 +
    // The runner builds, uploads the artifact and signs its receipt.
926 +
    let run = runs_of(&h.app, "andy", "demo").await.remove(0);
927 +
    let a = claim(&h.app).await.unwrap();
928 +
    let id = a["job_id"].as_i64().unwrap();
929 +
    let body: Vec<u8> = b"#!/bin/sh\necho tool\n".to_vec();
930 +
    let (_, text) = upload(&h.app, id, "tool", &body).await;
931 +
    let blob = serde_json::from_str::<serde_json::Value>(&text).unwrap()["blob"].as_str().unwrap().to_string();
932 +
    let mut r = receipt("build", a["pipeline_hash"].as_str().unwrap(), 0);
933 +
    r["outputs"] = serde_json::json!([blob]);
934 +
    let heads_run: Vec<ChangeHash> = run.head_list().into_iter().filter_map(ChangeHash::from_hex).collect();
935 +
    let state = hash_object(ObjectType::State, &StateObject::new(heads_run).encode());
936 +
    let repo_id = h.app.repos.open(&RepoName::parse("andy/demo").unwrap()).unwrap().id().unwrap();
937 +
    let sk = SecretKey::generate();
938 +
    let runner = sk.identity("test-1 (OxygenFlow runner)", "", 1);
939 +
    let fr: flow_core::Receipt = serde_json::from_value(r.clone()).unwrap();
940 +
    let signed = hex::encode(SignedReceipt::sign(fr.to_oxy(), repo_id, state, &format!("{url}/flow/1"), &runner, &sk).encode());
941 +
    let done = serde_json::json!({ "exit_code": 0, "duration_ms": 900, "receipt": r, "signed_receipt": signed });
942 +
    assert_eq!(finish(&h.app, id, done).await, StatusCode::NO_CONTENT);
943 +
944 +
    // A release with the built asset and a file uploaded by hand.
945 +
    let row = db::repos::get(&h.app.db, "andy", "demo").await.unwrap().unwrap();
946 +
    let rel = db::releases::create(&h.app.db, row.id, "v1", "One", "", false, None).await.unwrap();
947 +
    oxygenhub::web::releases::store_asset(&h.app, rel.id, "tool", "", &body).await.unwrap();
948 +
    oxygenhub::web::releases::store_asset(&h.app, rel.id, "notes.txt", "", b"by hand\n").await.unwrap();
949 +
950 +
    let (st, page) = call(&h.app, "GET", "/andy/demo/releases/trust/v1", None, None).await;
951 +
    assert_eq!(st, StatusCode::OK);
952 +
    for want in [
953 +
        "Tag v1 is signed",
954 +
        "The one change in the release is authenticated",
955 +
        "tool: signed receipt from run #1 build",
956 +
        "a runner key of this hub",
957 +
        "tool: built from the tagged state",
958 +
        "tool: pinned environment",
959 +
        "notes.txt: no build found",
960 +
        "<svg class=\"trust-graph\"",
961 +
    ] {
962 +
        assert!(page.contains(want), "missing {want:?}");
963 +
    }
964 +
    let (_, releases) = call(&h.app, "GET", "/andy/demo/releases", None, None).await;
965 +
    assert!(releases.contains("/andy/demo/releases/trust/v1"), "the release links its audit");
966 +
967 +
    // The export carries the signed receipt, which checks on its own.
968 +
    let (st, json) = call(&h.app, "GET", "/andy/demo/releases/trust/v1?format=json", None, None).await;
969 +
    assert_eq!(st, StatusCode::OK);
970 +
    let t: serde_json::Value = serde_json::from_str(&json).unwrap();
971 +
    assert_eq!((t["tag"].as_str(), t["tag_signature"].as_str(), t["changes"].as_i64()), (Some("v1"), Some("valid"), Some(1)));
972 +
    assert_eq!(t["authors"][0]["who"]["name"], "Andy");
973 +
    let tool = t["assets"].as_array().unwrap().iter().find(|a| a["name"] == "tool").unwrap();
974 +
    let hex_receipt = tool["build"]["signed_receipt"].as_str().unwrap();
975 +
    let sr = SignedReceipt::decode(&hex::decode(hex_receipt).unwrap()).unwrap();
976 +
    sr.verify().unwrap();
977 +
    assert_eq!(sr.receipt.outputs[0].to_hex(), blob);
978 +
    let audit = t["audit"].as_array().unwrap();
979 +
    assert!(audit.iter().any(|c| c["verdict"] == "warn" && c["title"].as_str().unwrap().starts_with("notes.txt")));
980 +
    assert!(!audit.iter().any(|c| c["verdict"] == "fail"), "{audit:?}");
981 +
    // The API gives the same report; an unknown release is a 404.
982 +
    let (st, api) = call(&h.app, "GET", "/api/v1/repos/andy/demo/releases/trust/v1", None, None).await;
983 +
    assert_eq!(st, StatusCode::OK);
984 +
    assert_eq!(serde_json::from_str::<serde_json::Value>(&api).unwrap()["assets"].as_array().unwrap().len(), 2);
985 +
    assert_eq!(call(&h.app, "GET", "/api/v1/repos/andy/demo/releases/trust/v9", None, None).await.0, StatusCode::NOT_FOUND);
986 +
    assert_eq!(call(&h.app, "GET", "/andy/demo/releases/trust/v9", None, None).await.0, StatusCode::NOT_FOUND);
987 +
}
modified docs/spec.md +15 −0
…
387 unchanged lines
388 388
389 389
Tags come from the repository (spec §10): a name, the state, and a signed attestation when the tagger signed. The tags page lists them with signature status. A release attaches to a tag: notes (Markdown, generated draft from the changes since the previous release), assets (uploaded files, content addressed, size limited by `asset_limit`), a pre-release flag, and a latest marker. Source archives (`.tar.zst`, `.zip`) of the tagged state are generated on demand and cached.
390 390
391 +
**Trust and audit** (§9.1). `/{owner}/{name}/releases/trust/{tag}` reports on a release; each release on the releases page links it.
392 +
- **Tag:** the tag's signer and whether its attestation verifies.
393 +
- **Authors:** everyone whose changes the release adds since the previous release (all of its changes for the first release). For each: how many changes are signed, sealed, unsigned, invalid or signed by revoked keys, and the account their key is linked to.
394 +
- **Assets:** for each asset, what built it: the run and job, the runner's identity (and whether it is a key one of this hub's runner tokens signs with), and the pinned environment (tier, rootfs hash, environment hash). Also its inputs (the state built, the pipeline hash and the scope hash) and its signed receipt, verified again from the repository.
395 +
- **Graph:** authors → tagged state → jobs → assets, drawn as SVG. A dashed edge is a build from another state than the tag's.
396 +
- **Audit:** pass, warn or fail for each claim:
397 +
  - the tag is signed;
398 +
  - the changes are authenticated;
399 +
  - each asset has a verifying signed receipt from a known runner;
400 +
  - each asset was built from the tagged state;
401 +
  - each asset's environment is pinned by hash.
402 +
403 +
  A hand-uploaded asset, an unsigned tag or an unsigned change is a warning. A signature or receipt that does not verify, or a revoked key, is a failure.
404 +
- **Export:** `?format=json` downloads the report, and `GET /api/v1/repos/{owner}/{name}/releases/trust/{tag}` returns the same. The JSON carries each signed receipt as hex, so an auditor can check it offline with `oxy receipt verify`.
405 +
391 406
**Built by.** A release asset links to the build that produced it: the passed OxygenFlow job of this repository with an artifact of the same BLAKE3 (hub spec §13.2). A job with a kept signed receipt comes first, then the newest run. The release page shows the run, the job and the signed receipt with its signer, and the API gives the same as `built_by` on each asset. On a run page, a writer can attach an artifact to a release; it becomes an asset named after the artifact's path, so the link follows by content. A file uploaded by hand links the same way when its bytes match.
392 407
393 408
## 10. Notifications
…
208 unchanged lines
modified templates/releases.html +1 −0
…
13 unchanged lines
14 14
  <h2><a href="{{ repo.url }}/releases/tag/{{ v.r.tag }}">{{ v.r.title }}</a>
15 15
    {% if v.latest %}<span class="tag head">latest</span>{% endif %}{% if v.r.prerelease %}<span class="tag warn">pre-release</span>{% endif %}
16 16
    <span class="small mute">{{ v.r.tag }}{% if let Some(s) = v.signer %} · <span class="sig sig-valid">✔</span> signed by {{ s }}{% else %} · <span class="sig sig-unsigned">○</span> unsigned tag{% endif %} · {{ v.r.author }} · <time title="{{ v.r.created_ms|when_i }}">{{ v.r.created_ms|ago_i }}</time></span></h2>
17 +
    <a class="small" href="{{ repo.url }}/releases/trust/{{ v.r.tag }}" title="Authors, signatures, builds and receipts, audited">Trust and audit</a>
17 18
  {% if !v.r.notes.is_empty() %}<article class="doc">{{ v.notes_html|safe }}</article>{% endif %}
18 19
  <h3>Assets</h3>
19 20
  <ul class="plain">
…
22 unchanged lines
modified templates/style.css +20 −0
…
546 unchanged lines
547 547
.vis-private { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 45%, transparent); }
548 548
summary.go { display: inline-block; user-select: none; }
549 549
.clone-pop > summary.go:hover { background: var(--soft); }
550 +
551 +
/* Release trust (hub spec §9.1) */
552 +
.audit ul.checks li { padding: 3px 0; }
553 +
.audit ul.checks li .glyph { display: inline-block; width: 1.2em; text-align: center; font-weight: 700; }
554 +
.audit ul.checks li.pass .glyph { color: var(--ok); }
555 +
.audit ul.checks li.warn .glyph { color: var(--warn); }
556 +
.audit ul.checks li.fail .glyph { color: var(--bad); }
557 +
.audit-fail { border-left: 3px solid var(--bad); }
558 +
.audit-warn { border-left: 3px solid var(--warn); }
559 +
.audit-pass { border-left: 3px solid var(--ok); }
560 +
.trust-graph-wrap { overflow-x: auto; }
561 +
svg.trust-graph { min-width: 720px; width: 100%; height: auto; }
562 +
svg.trust-graph .edge { fill: none; stroke: var(--line); stroke-width: 1.5; }
563 +
svg.trust-graph .edge.dashed { stroke-dasharray: 5 4; stroke: var(--warn); }
564 +
svg.trust-graph .node rect { fill: var(--panel); stroke: var(--line); }
565 +
svg.trust-graph .node.pass rect { stroke: var(--ok); }
566 +
svg.trust-graph .node.warn rect { stroke: var(--warn); }
567 +
svg.trust-graph .node.fail rect { stroke: var(--bad); }
568 +
svg.trust-graph text { font-size: 11px; fill: var(--ink); }
569 +
svg.trust-graph text.s { font-size: 10px; fill: var(--mute); }