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)
379        .with_context(|| format!("{} is not an object id", head.oid))?;
380
381    let mut chain = Chain {
382        repo: repo_ref.to_string(),
383        from: rev.to_owned(),
384        target: target.map(str::to_owned),
385        line: LinkState::Unproven(
386            "no line was named; pass `--line <path>:<n>` to close link 1".to_owned(),
387        ),
388        signature: LinkState::Unproven("not reached".to_owned()),
389        push: LinkState::Unproven("not reached".to_owned()),
390        log: LinkState::Unproven("not reached".to_owned()),
391        build: None,
392        build_log: None,
393    };
394
395    // ── link 1: the line, and the commit that wrote it ─────────────────────
396    let subject = match target {
397        None => tip,
398        Some(spec) => {
399            let (path, number) = spec.rsplit_once(':').with_context(|| {
400                format!("{spec:?} is not `<path>:<line>`; the line number is what makes it link 1")
401            })?;
402            let number: usize = number
403                .parse()
404                .with_context(|| format!("{number:?} is not a line number"))?;
405            anyhow::ensure!(number >= 1, "line numbers start at 1");
406            match blame(git, hash, tip, path, number - 1) {
407                Ok(Some(origin)) => {
408                    chain.line = LinkState::Proven(format!(
409                        "{path}:{number} was written by {} ({} commit(s) walked)",
410                        origin.commit, origin.visited
411                    ));
412                    origin.commit
413                }
414                Ok(None) => {
415                    chain.line = LinkState::Broken(format!("{path} has no line {number} at {rev}"));
416                    return Ok(chain);
417                }
418                Err(err) => {
419                    chain.line = LinkState::Unproven(format!("{err:#}"));
420                    return Ok(chain);
421                }
422            }
423        }
424    };
425
426    // ── link 2: is that commit signed, by a key this server knows ──────────
427    //
428    // Five answers, kept apart on purpose, because collapsing any two is the
429    // roadmap's named failure:
430    //   verified            → Proven, naming the key AND the ACL principal —
431    //                         "signed" without "by whom" is not something an
432    //                         auditor can act on
433    //   unsigned            → Unproven; nothing is wrong with it
434    //   not an ssh sig (PGP)→ Unproven; PRESENT and uncheckable is a hole,
435    //                         not an accusation — gunnar checked nothing
436    //   unknown key         → Broken; perfect cryptography by a stranger
437    //                         proves nothing about identity
438    //   invalid / malformed → Broken; the commit is not what was signed
439    let registered = stores.auth().signing_keys()?;
440    let keys: Vec<gunnar_core::ssh_key::PublicKey> = registered
441        .iter()
442        .filter_map(|key| gunnar_core::ssh_key::PublicKey::from_openssh(&key.openssh).ok())
443        .collect();
444    chain.signature = if keys.is_empty() {
445        // Not "unsigned": there is nothing to check against, which is a fact
446        // about the server and not about the commit.
447        LinkState::Unproven(
448            "this server holds no signing keys, so no commit signature can be checked".to_owned(),
449        )
450    } else {
451        use gunnar_policy::{signature::SignatureCheck, SignatureProblem};
452        match gunnar_policy::signature::check_commit(git, &subject, &keys, "git") {
453            Ok(SignatureCheck::Verified(sig)) => {
454                // The key table row is where the fingerprint becomes a person:
455                // `check_commit` already refused any key that is not in
456                // `registered`, so the lookup can only miss if the table
457                // changed under us — and then the honest text names the key
458                // alone rather than inventing an owner.
459                let owner = registered
460                    .iter()
461                    .find(|key| key.fingerprint_sha256 == sig.fingerprint)
462                    .and_then(|key| {
463                        stores
464                            .auth()
465                            .principal(&key.principal_id)
466                            .ok()
467                            .flatten()
468                            .map(|p| p.name)
469                    });
470                LinkState::Proven(match owner {
471                    Some(name) => format!(
472                        "{subject} is signed by {}, registered to {name}; the signature verifies",
473                        sig.fingerprint
474                    ),
475                    None => format!(
476                        "{subject} is signed by {}; the signature verifies",
477                        sig.fingerprint
478                    ),
479                })
480            }
481            Ok(SignatureCheck::Problem(problem)) => match problem {
482                SignatureProblem::Unsigned => LinkState::Unproven(format!(
483                    "{subject} is not signed. That is not a failed signature — this repository \
484                     does not require one"
485                )),
486                SignatureProblem::NotAnSshSignature { armor } => LinkState::Unproven(format!(
487                    "{subject} carries a signature that is not verifiable by this server \
488                     ({armor}); gunnar verifies ssh signatures (gpg.format=ssh), and a \
489                     signature it cannot check is a hole, not a verdict"
490                )),
491                SignatureProblem::KeyNotRegistered { fingerprint } => LinkState::Broken(format!(
492                    "{subject} is signed by {fingerprint}, a key this server does not know; \
493                     cryptography by a stranger proves nothing about identity"
494                )),
495                SignatureProblem::BadSignature {
496                    fingerprint,
497                    reason,
498                } => LinkState::Broken(format!(
499                    "{subject}'s signature by {fingerprint} is invalid: {reason}"
500                )),
501                // Malformed, WrongNamespace, and whatever `#[non_exhaustive]`
502                // adds later: a signature that is present, ssh-shaped and
503                // wrong is a checked failure, and a NEW problem variant must
504                // land on the accusing side rather than silently reading as a
505                // hole — a hole is a pass a reader forgives.
506                problem => {
507                    LinkState::Broken(format!("{subject}'s signature is invalid: {problem}"))
508                }
509            },
510            Err(err) => LinkState::Unproven(format!("reading {subject}: {err}")),
511        }
512    };
513
514    // ── link 3: which push landed it ───────────────────────────────────────
515    let log = stores.transparency();
516    // Indexed: the number link 4 proves against is the LINE the record sits
517    // at, which is not its position among the pushes once the metered
518    // appliance's entitlement lines share the log.
519    let indexed = log.records().read_indexed()?;
520    let records: Vec<&gunnar_proto::PushAttestation> = indexed.iter().map(|(_, r)| r).collect();
521    let wanted = subject.to_string();
522    let attested = indexed.iter().find(|(_, record)| {
523        record
524            .repo
525            .as_ref()
526            .is_some_and(|r| r.namespace == repo.namespace && r.repo == repo.repo)
527            && record.new_object_oids.contains(&wanted)
528    });
529    let index = match attested {
530        Some((index, record)) => {
531            let index = *index;
532            chain.push = LinkState::Proven(format!(
533                "pushed by {} with key {} at {} (pack {})",
534                record.principal_name,
535                record.key_fingerprint,
536                record.received_at_unix_ms,
537                record.pack_hash
538            ));
539            Some(index)
540        }
541        None => {
542            // The distinction that matters: a log with no object lists cannot
543            // answer this, and saying "no attestation" would blame the push.
544            let blind = records
545                .iter()
546                .any(|record| !record.new_object_oids_unknown.is_empty());
547            chain.push = LinkState::Unproven(if blind {
548                "this log holds attestations whose object set was never enumerated, so it \
549                 cannot say which push introduced this commit. Start the server with object \
550                 attestation on."
551                    .to_owned()
552            } else {
553                format!("no attestation in this log names {subject} as an object it introduced")
554            });
555            None
556        }
557    };
558
559    // ── link 4: and is that push in the log ────────────────────────────────
560    chain.log = match index {
561        None => LinkState::Unproven("no attestation to prove".to_owned()),
562        Some(index) => {
563            let key = stores.transparency_key()?;
564            let heads = log.heads()?;
565            let inclusion = log
566                .prove(index)?
567                .context("the record is in the log and has no proof, which cannot happen")?;
568            // A proof is only evidence against a head somebody signed. Without
569            // one it is arithmetic the server did about itself.
570            let covering = heads.iter().rev().find(|head| {
571                head.head.size as usize >= inclusion.size
572                    && gunnar_core::transparency::verify_tree_head(head, key.public_key()).is_ok()
573            });
574            match covering {
575                Some(head) if head.head.root == inclusion.root => LinkState::Proven(format!(
576                    "record {} of {} under the head signed at {} (root {})",
577                    inclusion.index, inclusion.size, head.head.at_unix_ms, head.head.root
578                )),
579                Some(head) => LinkState::Broken(format!(
580                    "the log's records hash to {} and the head signed at {} swore to {}",
581                    inclusion.root, head.head.at_unix_ms, head.head.root
582                )),
583                None => LinkState::Unproven(format!(
584                    "record {} of {} is in the log and no signed tree head covers it yet; run \
585                     `gunnar attest head`",
586                    inclusion.index, inclusion.size
587                )),
588            }
589        }
590    };
591
592    Ok(chain)
593}
594
595/// [`verify`] over a data root, opening the stores itself.
596pub fn verify_at(
597    data_root: &Path,
598    repo: &gunnar_proto::RepoPath,
599    rev: &str,
600    target: Option<&str>,
601) -> Result<Chain> {
602    let stores = std::sync::Arc::new(ServerStores::open(data_root)?);
603    let plane = crate::control_plane::CoreControlPlane::new(std::sync::Arc::clone(&stores));
604    verify(&stores, &plane, repo, rev, target)
605}
606
607/// **Walk the chain from an artifact**, not from a line.
608///
609/// This is the direction a verifier actually stands in: it holds a binary, or
610/// an image layer, and wants to get back to the source. So it starts at the
611/// artifact's SHA-256, finds the build record that claims to have produced it,
612/// checks that record's signature and its place in the build log, and then
613/// hands the commit that build was made from to [`verify`] — which closes links
614/// 1 to 4 exactly as it does for a line.
615///
616/// `line` is optional and is what makes the chain reach all the way down: with
617/// it, the answer names the commit that wrote that line of the source the
618/// artifact was built from.
619pub fn verify_artifact(
620    stores: &ServerStores,
621    plane: &crate::control_plane::CoreControlPlane,
622    artifact_sha256: &str,
623    line: Option<&str>,
624) -> Result<Chain> {
625    let builds = stores.build_log();
626    let key = stores.provenance_key()?;
627    let signed: Vec<gunnar_core::SignedBuild> = builds
628        .lines_of()?
629        .iter()
630        .map(|line| serde_json::from_str(line).context("decoding a build record"))
631        .collect::<Result<_>>()?;
632
633    let index = signed
634        .iter()
635        .position(|build| build.record.produced(artifact_sha256));
636    let Some(index) = index else {
637        anyhow::bail!(
638            "no build record in this log claims to have produced {artifact_sha256}. That is \
639             not a failed verification — nothing here has ever attested to that artifact."
640        )
641    };
642    let build = &signed[index];
643
644    // Link 5–6: the record, and whether it is really this builder's.
645    let build_state = match gunnar_core::provenance::verify_build(build, key.public_key()) {
646        Ok(()) => LinkState::Proven(format!(
647            "{} built {} from {}@{} ({} network)",
648            build.record.builder,
649            artifact_sha256,
650            build.record.source_repo,
651            build.record.source_commit,
652            if build.record.network { "with" } else { "no" },
653        )),
654        Err(problem) => LinkState::Broken(problem.to_string()),
655    };
656
657    // Link 7: is that record in the build log, under a signed head.
658    let log_key = stores.transparency_key()?;
659    let build_log_state = match builds.prove(index)? {
660        None => LinkState::Unproven("the record is not in the log it was read from".to_owned()),
661        Some(inclusion) => {
662            let heads = builds.heads()?;
663            let covering = heads.iter().rev().find(|head| {
664                head.head.size as usize >= inclusion.size
665                    && gunnar_core::transparency::verify_tree_head(head, log_key.public_key())
666                        .is_ok()
667            });
668            match covering {
669                Some(head) if head.head.root == inclusion.root => LinkState::Proven(format!(
670                    "record {} of {} under the head signed at {}",
671                    inclusion.index, inclusion.size, head.head.at_unix_ms
672                )),
673                Some(head) => LinkState::Broken(format!(
674                    "the build log's records hash to {} and the head signed at {} swore to {}",
675                    inclusion.root, head.head.at_unix_ms, head.head.root
676                )),
677                None => LinkState::Unproven(format!(
678                    "record {} of {} is in the build log and no signed head covers it yet; run \
679                     `gunnar attest head --builds`",
680                    inclusion.index, inclusion.size
681                )),
682            }
683        }
684    };
685
686    // ── a record that does not verify is NOT followed ──────────────────────
687    //
688    // Its fields are the attacker's at that point: the source repo and commit
689    // are exactly what a forged record would choose. Following them would let a
690    // broken signature produce a chain that looks mostly green because the
691    // *commit it pointed at* is genuine — and the first version of this
692    // function did precisely that, then failed with "names no commit" when the
693    // forgery happened to be nonsense.
694    //
695    // So the walk stops, and the links below it are unproven with the reason.
696    if build_state.is_broken() {
697        return Ok(Chain {
698            repo: build.record.source_repo.clone(),
699            from: format!("artifact {artifact_sha256}"),
700            target: line.map(str::to_owned),
701            line: LinkState::Unproven(
702                "the build record does not verify, so nothing it says about the source is \
703                 followed"
704                    .to_owned(),
705            ),
706            signature: LinkState::Unproven("not reached: the build record is broken".to_owned()),
707            push: LinkState::Unproven("not reached: the build record is broken".to_owned()),
708            log: LinkState::Unproven("not reached: the build record is broken".to_owned()),
709            build: Some(build_state),
710            build_log: Some(build_log_state),
711        });
712    }
713
714    // …and now the source half, from the commit the build names.
715    let (namespace, name) = build.record.source_repo.split_once('/').with_context(|| {
716        format!(
717            "the build record's source repo {:?} is not `<namespace>/<repo>`",
718            build.record.source_repo
719        )
720    })?;
721    let mut chain = verify(
722        stores,
723        plane,
724        &gunnar_proto::RepoPath::new(namespace, name),
725        &build.record.source_commit,
726        line,
727    )?;
728    chain.from = format!("artifact {artifact_sha256}");
729    chain.build = Some(build_state);
730    chain.build_log = Some(build_log_state);
731    Ok(chain)
732}