Skip to main content

gunnar_server/
verify.rs

1//! **`gunnar verify` — the unbroken chain, walked backwards.**
2//!
3//! The roadmap's whole thesis in one command: given a line of source, say which
4//! commit put it there, whether that commit is signed by a key this server
5//! knows, which push landed it, and whether that push is in the transparency
6//! log under a signed tree head.
7//!
8//! ```text
9//!   file:line  ──▶  commit  ──▶  signature  ──▶  push attestation  ──▶  log
10//!     link 1       link 1         link 2            link 3            link 4
11//! ```
12//!
13//! # What it proves, and where it stops today
14//!
15//! Links 1–4. The half above — a built artifact, its provenance, the image it
16//! went into — needs the runner and the registry (roadmap phases 3 and 4), and
17//! this module does not pretend to have them: [`Chain`] carries only the links
18//! it walked, and every one it could not walk is a named absence rather than a
19//! missing field.
20//!
21//! # Every link may be UNPROVEN, and that is a third answer
22//!
23//! Not `true`/`false`. A commit with no signature is not a commit whose
24//! signature failed; a push with no attestation is not a push that was refused.
25//! Collapsing the two would make an unsigned repository look identical to a
26//! forged one, which is the failure this whole design is written against, so
27//! [`LinkState`] has three values and the summary refuses to reduce them to a
28//! boolean.
29
30use std::path::Path;
31
32use anyhow::{Context, Result};
33use gunnar_store::git_storage_trait::{GitServe, ObjType};
34
35use crate::stores::ServerStores;
36
37/// How much history a blame will walk before giving up.
38///
39/// A bound rather than a promise: a line in a file with a hundred thousand
40/// revisions is a question this command should refuse rather than answer in an
41/// hour. Exceeding it is reported as [`LinkState::Unproven`] with the reason,
42/// never as "the line was introduced at the oldest commit we happened to look
43/// at".
44pub const BLAME_BUDGET: usize = 4096;
45
46/// What is known about one link of the chain.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum LinkState {
49    /// Checked, and it holds.
50    Proven(String),
51    /// Checked, and it does not. **This is the answer that must never be
52    /// confused with the one below.**
53    Broken(String),
54    /// Not checked, and why. Nothing was found wrong; nothing was found right.
55    Unproven(String),
56}
57
58impl LinkState {
59    pub fn is_proven(&self) -> bool {
60        matches!(self, LinkState::Proven(_))
61    }
62    pub fn is_broken(&self) -> bool {
63        matches!(self, LinkState::Broken(_))
64    }
65    fn mark(&self) -> &'static str {
66        match self {
67            LinkState::Proven(_) => "ok  ",
68            LinkState::Broken(_) => "BAD ",
69            LinkState::Unproven(_) => "??  ",
70        }
71    }
72    fn detail(&self) -> &str {
73        match self {
74            LinkState::Proven(d) | LinkState::Broken(d) | LinkState::Unproven(d) => d,
75        }
76    }
77}
78
79/// The chain, as far as it could be walked.
80#[derive(Debug, Clone)]
81pub struct Chain {
82    /// `<namespace>/<repo>`.
83    pub repo: String,
84    /// The revision the walk started from.
85    pub from: String,
86    /// `path:line`, one-based, as the operator wrote it.
87    pub target: Option<String>,
88    /// Link 1: which commit put the line there.
89    pub line: LinkState,
90    /// Link 2: the commit's own signature.
91    pub signature: LinkState,
92    /// Link 3: the push that landed the commit.
93    pub push: LinkState,
94    /// Link 4: that push's place in the transparency log.
95    pub log: LinkState,
96    /// Links 5–6: the build that produced the artifact, and its signature.
97    ///
98    /// `None` when no artifact was named — the chain was walked from a line of
99    /// source and the build half was never asked about. Distinct from a link
100    /// that was asked about and could not be proven.
101    pub build: Option<LinkState>,
102    /// Link 7: the build record's place in the build log.
103    pub build_log: Option<LinkState>,
104}
105
106impl Chain {
107    /// Every link, in chain order, with a stable id and a print label.
108    ///
109    /// The ONE enumeration (one implementation): [`std::fmt::Display`] prints the labels,
110    /// [`Chain::to_proto`] ships the ids, and both walk this list — so a link
111    /// added here reaches the CLI and `Admin.VerifyChain` together, and cannot
112    /// reach one without the other.
113    pub fn links(&self) -> Vec<(&'static str, &'static str, &LinkState)> {
114        let mut out: Vec<(&'static str, &'static str, &LinkState)> = Vec::new();
115        // Chain order, artifact-first: a verifier starts where it is standing,
116        // and where it is standing is next to the thing it deployed.
117        if let Some(state) = &self.build_log {
118            out.push(("build_log", "7 build in log   ", state));
119        }
120        if let Some(state) = &self.build {
121            out.push(("build", "5 artifact built ", state));
122        }
123        out.push(("line", "1 line → commit  ", &self.line));
124        out.push(("signature", "2 commit signed  ", &self.signature));
125        out.push(("push", "3 push attested  ", &self.push));
126        out.push(("log", "4 in the log     ", &self.log));
127        out
128    }
129
130    /// The chain rendered for `Admin.VerifyChain` — a projection of
131    /// [`Chain::links`], never a second verdict.
132    pub fn to_proto(&self) -> gunnar_proto::VerifyChainResponse {
133        use gunnar_proto::{ChainLink, ChainLinkVerdict, ChainSummary};
134        gunnar_proto::VerifyChainResponse {
135            repo: self.repo.clone(),
136            from: self.from.clone(),
137            target: self.target.clone().unwrap_or_default(),
138            links: self
139                .links()
140                .iter()
141                .map(|(id, _, state)| ChainLink {
142                    id: (*id).to_owned(),
143                    verdict: match state {
144                        LinkState::Proven(_) => ChainLinkVerdict::ChainLinkProven,
145                        LinkState::Broken(_) => ChainLinkVerdict::ChainLinkBroken,
146                        LinkState::Unproven(_) => ChainLinkVerdict::ChainLinkUnproven,
147                    } as i32,
148                    detail: state.detail().to_owned(),
149                })
150                .collect(),
151            summary: if self.is_unbroken() {
152                ChainSummary::ChainUnbroken
153            } else if self.is_broken() {
154                ChainSummary::ChainBroken
155            } else {
156                ChainSummary::ChainIncomplete
157            } as i32,
158        }
159    }
160
161    /// **Unbroken** means every link is proven. A single `Unproven` is not
162    /// unbroken: an unverified link is a hole in the chain, and calling it
163    /// green is exactly the false confidence the roadmap's `Risker` section
164    /// names.
165    pub fn is_unbroken(&self) -> bool {
166        self.links().iter().all(|(_, _, state)| state.is_proven())
167    }
168
169    /// `true` if anything was actively wrong, as opposed to merely unchecked.
170    pub fn is_broken(&self) -> bool {
171        self.links().iter().any(|(_, _, state)| state.is_broken())
172    }
173}
174
175impl std::fmt::Display for Chain {
176    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177        writeln!(f, "repo   {}", self.repo)?;
178        writeln!(f, "from   {}", self.from)?;
179        if let Some(target) = &self.target {
180            writeln!(f, "target {target}")?;
181        }
182        writeln!(f)?;
183        for (_, label, state) in self.links() {
184            writeln!(f, "  {} {}  {}", state.mark(), label, state.detail())?;
185        }
186        writeln!(f)?;
187        if self.is_unbroken() {
188            write!(f, "UNBROKEN: every link is proven.")
189        } else if self.is_broken() {
190            write!(
191                f,
192                "BROKEN: a link was checked and did not hold. See the BAD line(s) above."
193            )
194        } else {
195            write!(
196                f,
197                "INCOMPLETE: nothing is wrong and not everything is proven. An unchecked link \
198                 is a hole, not a pass."
199            )
200        }
201    }
202}
203
204/// Where a line came from.
205struct Origin {
206    commit: gix_hash::ObjectId,
207    /// How many commits the walk visited to find it.
208    visited: usize,
209}
210
211/// The blob at `path` in `commit`'s tree, or `None` if the path is not there.
212///
213/// `pub(crate)` because it is the one tree/blob reader link-1 rests on, and a
214/// whole-file blame ([`crate::blame`]) is that same read for every line: it
215/// must resolve a path to a blob at a commit exactly as this does, or the two
216/// answers can disagree (one implementation).
217pub(crate) fn blob_at(
218    git: &dyn GitServe,
219    hash: gix_hash::Kind,
220    commit: gix_hash::ObjectId,
221    path: &str,
222) -> Result<Option<(gix_hash::ObjectId, Vec<u8>)>> {
223    let (kind, bytes) = git
224        .read(commit.as_bytes())?
225        .with_context(|| format!("commit {commit} is not in this repository"))?;
226    anyhow::ensure!(kind == ObjType::Commit, "{commit} is not a commit");
227    let mut tree = gunnar_store::object::commit(&bytes, hash)
228        .with_context(|| format!("decoding commit {commit}"))?
229        .tree;
230
231    let mut components = path.split('/').peekable();
232    while let Some(component) = components.next() {
233        let (kind, bytes) = match git.read(tree.as_bytes())? {
234            Some(pair) => pair,
235            None => return Ok(None),
236        };
237        if kind != ObjType::Tree {
238            return Ok(None);
239        }
240        let mut found = None;
241        for entry in gunnar_store::object::TreeIter::from_bytes(&bytes, hash).named() {
242            let entry = entry.with_context(|| format!("decoding tree {tree}"))?;
243            if entry.name == component.as_bytes() {
244                found = Some(entry);
245                break;
246            }
247        }
248        let Some(entry) = found else {
249            return Ok(None);
250        };
251        if components.peek().is_some() {
252            tree = entry.oid;
253            continue;
254        }
255        // Last component: it must be a blob for a line to live in it.
256        let Some((ObjType::Blob, blob)) = git.read(entry.oid.as_bytes())? else {
257            return Ok(None);
258        };
259        return Ok(Some((entry.oid, blob)));
260    }
261    Ok(None)
262}
263
264/// A commit's parents, in order.
265///
266/// `pub(crate)` for the same reason as [`blob_at`]: the blame walk steps through
267/// parents the way link-1 does, and sharing the one reader keeps them from
268/// drifting.
269pub(crate) fn parents(
270    git: &dyn GitServe,
271    hash: gix_hash::Kind,
272    commit: gix_hash::ObjectId,
273) -> Result<Vec<gix_hash::ObjectId>> {
274    let (_, bytes) = git
275        .read(commit.as_bytes())?
276        .with_context(|| format!("commit {commit} is not in this repository"))?;
277    Ok(gunnar_store::object::commit(&bytes, hash)
278        .with_context(|| format!("decoding commit {commit}"))?
279        .parents)
280}
281
282/// **Which commit put this line here.**
283///
284/// Walks back from `tip`, following the parent the line came *through* — for a
285/// merge that means trying each parent in order and taking the first one the
286/// line traces back into, which is what git's blame does and what makes a line
287/// merged from a side branch attributable to the branch rather than to the
288/// merge.
289///
290/// Stops when the line has no ancestor in any parent: that commit is where it
291/// was written.
292fn blame(
293    git: &dyn GitServe,
294    hash: gix_hash::Kind,
295    tip: gix_hash::ObjectId,
296    path: &str,
297    line: usize,
298) -> Result<Option<Origin>> {
299    let mut commit = tip;
300    let mut line = line;
301    for visited in 1..=BLAME_BUDGET {
302        let Some((_, blob)) = blob_at(git, hash, commit, path)? else {
303            // The path is not in this commit at all. Only reachable for the
304            // very first one — the walk never steps into a parent that lacks
305            // it — so it means the caller asked about a file that is not there.
306            return Ok(None);
307        };
308        let current = crate::diff::split_lines(&blob);
309        if line >= current.len() {
310            return Ok(None);
311        }
312        let mut stepped = None;
313        for parent in parents(git, hash, commit)? {
314            let Some((_, parent_blob)) = blob_at(git, hash, parent, path)? else {
315                continue;
316            };
317            let older = crate::diff::split_lines(&parent_blob);
318            // Identical blobs: the line certainly survived, and tracing it is
319            // both unnecessary and the expensive path.
320            if parent_blob == blob {
321                stepped = Some((parent, line));
322                break;
323            }
324            if let Some(at) = crate::diff::trace_line(&older, &current, line) {
325                stepped = Some((parent, at));
326                break;
327            }
328        }
329        match stepped {
330            Some((parent, at)) => {
331                commit = parent;
332                line = at;
333            }
334            // No parent holds this line: it was written here.
335            None => return Ok(Some(Origin { commit, visited })),
336        }
337    }
338    anyhow::bail!(
339        "the blame walked {BLAME_BUDGET} commits without finding where the line was written; \
340         this is a bound, not an answer"
341    )
342}
343
344/// Walk the chain for one line of one file.
345///
346/// `target` is `path:line`, one-based, the way an editor and `git blame` count.
347/// `None` verifies the revision itself — links 2 to 4 for the commit at `rev`,
348/// with link 1 reported as unchecked rather than as passed.
349pub fn verify(
350    stores: &ServerStores,
351    plane: &crate::control_plane::CoreControlPlane,
352    repo: &gunnar_proto::RepoPath,
353    rev: &str,
354    target: Option<&str>,
355) -> Result<Chain> {
356    use gunnar_grpc::ControlPlane as _;
357
358    let repo_ref = gunnar_core::RepoRef::new(&repo.namespace, &repo.repo)
359        .context("the repository path is not a legal one")?;
360    let entry = stores
361        .catalog()
362        .get(&repo_ref)?
363        .with_context(|| format!("no such repository: {repo_ref}"))?;
364    let store = stores.repo_store(&entry)?;
365    let git = store.serve();
366    let hash = store.hash_kind();
367
368    // The revision, through the same resolver `Refs.Log` uses, so `HEAD`, a
369    // branch and a hex oid all mean here what they mean there.
370    // `touching` empty: the walk wants the tip, not the history of one path.
371    let commits = plane
372        .log(repo, rev, 1, "")
373        .map_err(|err| anyhow::anyhow!("{err}"))?;
374    let head = commits
375        .first()
376        .with_context(|| format!("{rev} names no commit in {repo_ref}"))?;
377    let tip = gix_hash::ObjectId::from_hex(head.oid.as_bytes())
378        .map_err(gix_error::Error::from).with_context(|| format!("{} is not an object id", head.oid))?;
379
380    let mut chain = Chain {
381        repo: repo_ref.to_string(),
382        from: rev.to_owned(),
383        target: target.map(str::to_owned),
384        line: LinkState::Unproven(
385            "no line was named; pass `--line <path>:<n>` to close link 1".to_owned(),
386        ),
387        signature: LinkState::Unproven("not reached".to_owned()),
388        push: LinkState::Unproven("not reached".to_owned()),
389        log: LinkState::Unproven("not reached".to_owned()),
390        build: None,
391        build_log: None,
392    };
393
394    // ── link 1: the line, and the commit that wrote it ─────────────────────
395    let subject = match target {
396        None => tip,
397        Some(spec) => {
398            let (path, number) = spec.rsplit_once(':').with_context(|| {
399                format!("{spec:?} is not `<path>:<line>`; the line number is what makes it link 1")
400            })?;
401            let number: usize = number
402                .parse()
403                .with_context(|| format!("{number:?} is not a line number"))?;
404            anyhow::ensure!(number >= 1, "line numbers start at 1");
405            match blame(git, hash, tip, path, number - 1) {
406                Ok(Some(origin)) => {
407                    chain.line = LinkState::Proven(format!(
408                        "{path}:{number} was written by {} ({} commit(s) walked)",
409                        origin.commit, origin.visited
410                    ));
411                    origin.commit
412                }
413                Ok(None) => {
414                    chain.line = LinkState::Broken(format!("{path} has no line {number} at {rev}"));
415                    return Ok(chain);
416                }
417                Err(err) => {
418                    chain.line = LinkState::Unproven(format!("{err:#}"));
419                    return Ok(chain);
420                }
421            }
422        }
423    };
424
425    // ── link 2: is that commit signed, by a key this server knows ──────────
426    //
427    // Five answers, kept apart on purpose, because collapsing any two is the
428    // roadmap's named failure:
429    //   verified            → Proven, naming the key AND the ACL principal —
430    //                         "signed" without "by whom" is not something an
431    //                         auditor can act on
432    //   unsigned            → Unproven; nothing is wrong with it
433    //   not an ssh sig (PGP)→ Unproven; PRESENT and uncheckable is a hole,
434    //                         not an accusation — gunnar checked nothing
435    //   unknown key         → Broken; perfect cryptography by a stranger
436    //                         proves nothing about identity
437    //   invalid / malformed → Broken; the commit is not what was signed
438    let registered = stores.auth().signing_keys()?;
439    let keys: Vec<gunnar_core::ssh_key::PublicKey> = registered
440        .iter()
441        .filter_map(|key| gunnar_core::ssh_key::PublicKey::from_openssh(&key.openssh).ok())
442        .collect();
443    chain.signature = if keys.is_empty() {
444        // Not "unsigned": there is nothing to check against, which is a fact
445        // about the server and not about the commit.
446        LinkState::Unproven(
447            "this server holds no signing keys, so no commit signature can be checked".to_owned(),
448        )
449    } else {
450        use gunnar_policy::{signature::SignatureCheck, SignatureProblem};
451        match gunnar_policy::signature::check_commit(git, &subject, &keys, "git") {
452            Ok(SignatureCheck::Verified(sig)) => {
453                // The key table row is where the fingerprint becomes a person:
454                // `check_commit` already refused any key that is not in
455                // `registered`, so the lookup can only miss if the table
456                // changed under us — and then the honest text names the key
457                // alone rather than inventing an owner.
458                let owner = registered
459                    .iter()
460                    .find(|key| key.fingerprint_sha256 == sig.fingerprint)
461                    .and_then(|key| {
462                        stores
463                            .auth()
464                            .principal(&key.principal_id)
465                            .ok()
466                            .flatten()
467                            .map(|p| p.name)
468                    });
469                LinkState::Proven(match owner {
470                    Some(name) => format!(
471                        "{subject} is signed by {}, registered to {name}; the signature verifies",
472                        sig.fingerprint
473                    ),
474                    None => format!(
475                        "{subject} is signed by {}; the signature verifies",
476                        sig.fingerprint
477                    ),
478                })
479            }
480            Ok(SignatureCheck::Problem(problem)) => match problem {
481                SignatureProblem::Unsigned => LinkState::Unproven(format!(
482                    "{subject} is not signed. That is not a failed signature — this repository \
483                     does not require one"
484                )),
485                SignatureProblem::NotAnSshSignature { armor } => LinkState::Unproven(format!(
486                    "{subject} carries a signature that is not verifiable by this server \
487                     ({armor}); gunnar verifies ssh signatures (gpg.format=ssh), and a \
488                     signature it cannot check is a hole, not a verdict"
489                )),
490                SignatureProblem::KeyNotRegistered { fingerprint } => LinkState::Broken(format!(
491                    "{subject} is signed by {fingerprint}, a key this server does not know; \
492                     cryptography by a stranger proves nothing about identity"
493                )),
494                SignatureProblem::BadSignature {
495                    fingerprint,
496                    reason,
497                } => LinkState::Broken(format!(
498                    "{subject}'s signature by {fingerprint} is invalid: {reason}"
499                )),
500                // Malformed, WrongNamespace, and whatever `#[non_exhaustive]`
501                // adds later: a signature that is present, ssh-shaped and
502                // wrong is a checked failure, and a NEW problem variant must
503                // land on the accusing side rather than silently reading as a
504                // hole — a hole is a pass a reader forgives.
505                problem => LinkState::Broken(format!(
506                    "{subject}'s signature is invalid: {problem}"
507                )),
508            },
509            Err(err) => LinkState::Unproven(format!("reading {subject}: {err}")),
510        }
511    };
512
513    // ── link 3: which push landed it ───────────────────────────────────────
514    let log = stores.transparency();
515    // Indexed: the number link 4 proves against is the LINE the record sits
516    // at, which is not its position among the pushes once the metered
517    // appliance's entitlement lines share the log.
518    let indexed = log.records().read_indexed()?;
519    let records: Vec<&gunnar_proto::PushAttestation> = indexed.iter().map(|(_, r)| r).collect();
520    let wanted = subject.to_string();
521    let attested = indexed.iter().find(|(_, record)| {
522        record
523            .repo
524            .as_ref()
525            .is_some_and(|r| r.namespace == repo.namespace && r.repo == repo.repo)
526            && record.new_object_oids.contains(&wanted)
527    });
528    let index = match attested {
529        Some((index, record)) => {
530            let index = *index;
531            chain.push = LinkState::Proven(format!(
532                "pushed by {} with key {} at {} (pack {})",
533                record.principal_name,
534                record.key_fingerprint,
535                record.received_at_unix_ms,
536                record.pack_hash
537            ));
538            Some(index)
539        }
540        None => {
541            // The distinction that matters: a log with no object lists cannot
542            // answer this, and saying "no attestation" would blame the push.
543            let blind = records
544                .iter()
545                .any(|record| !record.new_object_oids_unknown.is_empty());
546            chain.push = LinkState::Unproven(if blind {
547                "this log holds attestations whose object set was never enumerated, so it \
548                 cannot say which push introduced this commit. Start the server with object \
549                 attestation on."
550                    .to_owned()
551            } else {
552                format!("no attestation in this log names {subject} as an object it introduced")
553            });
554            None
555        }
556    };
557
558    // ── link 4: and is that push in the log ────────────────────────────────
559    chain.log = match index {
560        None => LinkState::Unproven("no attestation to prove".to_owned()),
561        Some(index) => {
562            let key = stores.transparency_key()?;
563            let heads = log.heads()?;
564            let inclusion = log
565                .prove(index)?
566                .context("the record is in the log and has no proof, which cannot happen")?;
567            // A proof is only evidence against a head somebody signed. Without
568            // one it is arithmetic the server did about itself.
569            let covering = heads.iter().rev().find(|head| {
570                head.head.size as usize >= inclusion.size
571                    && gunnar_core::transparency::verify_tree_head(head, key.public_key()).is_ok()
572            });
573            match covering {
574                Some(head) if head.head.root == inclusion.root => LinkState::Proven(format!(
575                    "record {} of {} under the head signed at {} (root {})",
576                    inclusion.index, inclusion.size, head.head.at_unix_ms, head.head.root
577                )),
578                Some(head) => LinkState::Broken(format!(
579                    "the log's records hash to {} and the head signed at {} swore to {}",
580                    inclusion.root, head.head.at_unix_ms, head.head.root
581                )),
582                None => LinkState::Unproven(format!(
583                    "record {} of {} is in the log and no signed tree head covers it yet; run \
584                     `gunnar attest head`",
585                    inclusion.index, inclusion.size
586                )),
587            }
588        }
589    };
590
591    Ok(chain)
592}
593
594/// [`verify`] over a data root, opening the stores itself.
595pub fn verify_at(
596    data_root: &Path,
597    repo: &gunnar_proto::RepoPath,
598    rev: &str,
599    target: Option<&str>,
600) -> Result<Chain> {
601    let stores = std::sync::Arc::new(ServerStores::open(data_root)?);
602    let plane = crate::control_plane::CoreControlPlane::new(std::sync::Arc::clone(&stores));
603    verify(&stores, &plane, repo, rev, target)
604}
605
606/// **Walk the chain from an artifact**, not from a line.
607///
608/// This is the direction a verifier actually stands in: it holds a binary, or
609/// an image layer, and wants to get back to the source. So it starts at the
610/// artifact's SHA-256, finds the build record that claims to have produced it,
611/// checks that record's signature and its place in the build log, and then
612/// hands the commit that build was made from to [`verify`] — which closes links
613/// 1 to 4 exactly as it does for a line.
614///
615/// `line` is optional and is what makes the chain reach all the way down: with
616/// it, the answer names the commit that wrote that line of the source the
617/// artifact was built from.
618pub fn verify_artifact(
619    stores: &ServerStores,
620    plane: &crate::control_plane::CoreControlPlane,
621    artifact_sha256: &str,
622    line: Option<&str>,
623) -> Result<Chain> {
624    let builds = stores.build_log();
625    let key = stores.provenance_key()?;
626    let signed: Vec<gunnar_core::SignedBuild> = builds
627        .lines_of()?
628        .iter()
629        .map(|line| serde_json::from_str(line).context("decoding a build record"))
630        .collect::<Result<_>>()?;
631
632    let index = signed
633        .iter()
634        .position(|build| build.record.produced(artifact_sha256));
635    let Some(index) = index else {
636        anyhow::bail!(
637            "no build record in this log claims to have produced {artifact_sha256}. That is \
638             not a failed verification — nothing here has ever attested to that artifact."
639        )
640    };
641    let build = &signed[index];
642
643    // Link 5–6: the record, and whether it is really this builder's.
644    let build_state = match gunnar_core::provenance::verify_build(build, key.public_key()) {
645        Ok(()) => LinkState::Proven(format!(
646            "{} built {} from {}@{} ({} network)",
647            build.record.builder,
648            artifact_sha256,
649            build.record.source_repo,
650            build.record.source_commit,
651            if build.record.network { "with" } else { "no" },
652        )),
653        Err(problem) => LinkState::Broken(problem.to_string()),
654    };
655
656    // Link 7: is that record in the build log, under a signed head.
657    let log_key = stores.transparency_key()?;
658    let build_log_state = match builds.prove(index)? {
659        None => LinkState::Unproven("the record is not in the log it was read from".to_owned()),
660        Some(inclusion) => {
661            let heads = builds.heads()?;
662            let covering = heads.iter().rev().find(|head| {
663                head.head.size as usize >= inclusion.size
664                    && gunnar_core::transparency::verify_tree_head(head, log_key.public_key())
665                        .is_ok()
666            });
667            match covering {
668                Some(head) if head.head.root == inclusion.root => LinkState::Proven(format!(
669                    "record {} of {} under the head signed at {}",
670                    inclusion.index, inclusion.size, head.head.at_unix_ms
671                )),
672                Some(head) => LinkState::Broken(format!(
673                    "the build log's records hash to {} and the head signed at {} swore to {}",
674                    inclusion.root, head.head.at_unix_ms, head.head.root
675                )),
676                None => LinkState::Unproven(format!(
677                    "record {} of {} is in the build log and no signed head covers it yet; run \
678                     `gunnar attest head --builds`",
679                    inclusion.index, inclusion.size
680                )),
681            }
682        }
683    };
684
685    // ── a record that does not verify is NOT followed ──────────────────────
686    //
687    // Its fields are the attacker's at that point: the source repo and commit
688    // are exactly what a forged record would choose. Following them would let a
689    // broken signature produce a chain that looks mostly green because the
690    // *commit it pointed at* is genuine — and the first version of this
691    // function did precisely that, then failed with "names no commit" when the
692    // forgery happened to be nonsense.
693    //
694    // So the walk stops, and the links below it are unproven with the reason.
695    if build_state.is_broken() {
696        return Ok(Chain {
697            repo: build.record.source_repo.clone(),
698            from: format!("artifact {artifact_sha256}"),
699            target: line.map(str::to_owned),
700            line: LinkState::Unproven(
701                "the build record does not verify, so nothing it says about the source is \
702                 followed"
703                    .to_owned(),
704            ),
705            signature: LinkState::Unproven("not reached: the build record is broken".to_owned()),
706            push: LinkState::Unproven("not reached: the build record is broken".to_owned()),
707            log: LinkState::Unproven("not reached: the build record is broken".to_owned()),
708            build: Some(build_state),
709            build_log: Some(build_log_state),
710        });
711    }
712
713    // …and now the source half, from the commit the build names.
714    let (namespace, name) = build.record.source_repo.split_once('/').with_context(|| {
715        format!(
716            "the build record's source repo {:?} is not `<namespace>/<repo>`",
717            build.record.source_repo
718        )
719    })?;
720    let mut chain = verify(
721        stores,
722        plane,
723        &gunnar_proto::RepoPath::new(namespace, name),
724        &build.record.source_commit,
725        line,
726    )?;
727    chain.from = format!("artifact {artifact_sha256}");
728    chain.build = Some(build_state);
729    chain.build_log = Some(build_log_state);
730    Ok(chain)
731}