Skip to main content

speccoder_runtime/attest/
branch.rs

1//! `speccoder attest verify --branch` (and `attest badge`): every
2//! ticked task of the units a branch touches, mapped to the commit that
3//! carries its evidence and checked with `attest verify`'s own rules
4//! ([`super::verify::object`]), plus what only a branch can show: a tick
5//! with no evidence at all, a task line edited after its tick, a cited
6//! requirement changed since, a commit a squash merge lost.
7//!
8//! Everything is read from git objects of the repository at the given
9//! head, never from a working tree, and trust comes only from the caller:
10//! a key the branch itself declares is never trusted.
11
12use std::collections::{BTreeMap, BTreeSet};
13use std::path::{Path, PathBuf};
14
15use serde_json::{Value, json};
16use speccoder_core::predicate::TaskVerificationV0;
17use speccoder_core::verify_branch::{
18    self, Anchor, BranchReport, BranchTotals, Conclusion, Mode, SkippedTask, Tick, TickKind,
19    TickOutcome, TickReport, Unverifiable,
20};
21use speccoder_core::{
22    CommandData, CommandResult, CommandStatus, ObjectVerification, TraceRow, VerifyOutcome,
23};
24
25use super::verify::{Context, Target, Trust};
26use super::{notes, statement};
27use crate::format::Adapter;
28use crate::git::Git;
29
30/// Field and record separators for `git log` output.
31const FS: char = '\u{1f}';
32const RS: char = '\u{1e}';
33
34/// Where the verifier's SSH trust comes from.
35#[derive(Debug, Clone)]
36pub enum AllowedSigners {
37    /// A file on the verifier's machine.
38    File(PathBuf),
39    /// A file read from git at a revision (`<rev>:<path>`): in CI, the base
40    /// branch's, never the head's.
41    Rev {
42        /// The revision (the base).
43        rev: String,
44        /// The repository path.
45        path: String,
46    },
47}
48
49impl AllowedSigners {
50    /// `<rev>:<path>`, as `--allowed-signers-rev` takes it.
51    ///
52    /// # Errors
53    ///
54    /// No `:` separating a revision and a path.
55    pub fn parse_rev(spec: &str) -> Result<Self, String> {
56        match spec.split_once(':') {
57            Some((rev, path)) if !rev.is_empty() && !path.is_empty() => Ok(Self::Rev {
58                rev: rev.to_owned(),
59                path: path.to_owned(),
60            }),
61            _ => Err(format!(
62                "--allowed-signers-rev takes <rev>:<path>, e.g. origin/main:.github/speccoder/allowed_signers (got `{spec}`)"
63            )),
64        }
65    }
66}
67
68/// What a branch check is asked.
69#[derive(Debug, Clone)]
70pub struct Options {
71    /// The commit to check (`HEAD` by default).
72    pub head: String,
73    /// The branch it is compared with; units touched since their merge
74    /// base are checked. `None`: every unit.
75    pub base: Option<String>,
76    /// Every unit, whatever the base.
77    pub all: bool,
78    /// SSH trust.
79    pub allowed_signers: Option<AllowedSigners>,
80    /// The Sigstore certificate identity.
81    pub identity: Option<String>,
82    /// The Sigstore OIDC issuer.
83    pub issuer: Option<String>,
84    /// Unsigned evidence is unverifiable (default).
85    pub require_signed: bool,
86    /// `regression_only` and `attested` fail too.
87    pub require_verified: bool,
88}
89
90impl Default for Options {
91    fn default() -> Self {
92        Self {
93            head: "HEAD".to_owned(),
94            base: None,
95            all: false,
96            allowed_signers: None,
97            identity: None,
98            issuer: None,
99            require_signed: true,
100            require_verified: false,
101        }
102    }
103}
104
105/// A check that could not start.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct Refusal {
108    /// How it ends.
109    pub status: CommandStatus,
110    /// Why, and what to do.
111    pub message: String,
112}
113
114impl Refusal {
115    /// A check that could not read what it checks: an error (exit 1), never
116    /// `neutral`.
117    fn internal(what: impl std::fmt::Display) -> Self {
118        Self::new(
119            CommandStatus::Error,
120            format!("the check could not read the branch: {what}; nothing was verified"),
121        )
122    }
123
124    fn new(status: CommandStatus, message: impl Into<String>) -> Self {
125        Self {
126            status,
127            message: message.into(),
128        }
129    }
130}
131
132/// The refusal when the caller gave no trust.
133pub const NO_TRUST: &str = "no trust configured: pass --allowed-signers or --certificate-identity/--certificate-oidc-issuer";
134
135/// A finished check: the report and where it was read from.
136#[derive(Debug)]
137pub struct Checked {
138    /// The report.
139    pub report: BranchReport,
140    /// The repository's top level.
141    pub repo: PathBuf,
142    /// The project's path in the repository (`""`, or ending in `/`).
143    pub prefix: String,
144}
145
146/// `attest verify --branch` for the repository
147/// containing `start`, as a command result named `command` (payload
148/// `branch_verify`).
149#[must_use]
150pub fn verify(start: &Path, opts: &Options, command: &str) -> CommandResult {
151    match check(start, opts) {
152        Ok(c) => result(command, c.report),
153        Err(r) => CommandResult::new(command, r.status, &r.message),
154    }
155}
156
157/// The report as a command result.
158#[must_use]
159pub fn result(command: &str, report: BranchReport) -> CommandResult {
160    let status = match report.conclusion {
161        Conclusion::Success => CommandStatus::Ok,
162        Conclusion::Failure => CommandStatus::AttestationFailed,
163        Conclusion::Neutral => CommandStatus::NotFound,
164    };
165    let mut message = report.title();
166    if report.accepted_unsigned {
167        message.push_str("; unsigned ticks were accepted (--require-signed=false)");
168    }
169    CommandResult::new(command, status, &message)
170        .with_data(CommandData::BranchVerify(Box::new(report)))
171}
172
173/// Resolves the caller's trust; the temporary directory holds a file read
174/// from git, and must live as long as the trust is used.
175/// With `optional`, no trust is no refusal: signatures go unchecked (a
176/// tour, which shows what is recorded and says so).
177fn trust(
178    top: &Path,
179    opts: &Options,
180    optional: bool,
181) -> Result<(Option<Trust>, Value, Option<tempfile::TempDir>), Refusal> {
182    let sigstore = opts.identity.is_some() && opts.issuer.is_some();
183    if opts.identity.is_some() != opts.issuer.is_some() {
184        return Err(Refusal::new(
185            CommandStatus::UsageError,
186            "--certificate-identity and --certificate-oidc-issuer go together",
187        ));
188    }
189    let mut held = None;
190    let (allowed, source) = match &opts.allowed_signers {
191        None => (None, Value::Null),
192        Some(AllowedSigners::File(p)) => {
193            if !p.is_file() {
194                return Err(Refusal::new(
195                    CommandStatus::UsageError,
196                    format!("--allowed-signers {}: no such file", p.display()),
197                ));
198            }
199            (Some(p.clone()), json!({ "file": p.display().to_string() }))
200        }
201        Some(AllowedSigners::Rev { rev, path }) => {
202            let bytes = Git::at(top)
203                .run(&["show", &format!("{rev}:{path}")])
204                .map_err(|e| {
205                    Refusal::new(
206                        CommandStatus::UsageError,
207                        format!(
208                            "--allowed-signers-rev {rev}:{path}: cannot read it ({})",
209                            e.0
210                        ),
211                    )
212                })?;
213            let dir = tempfile::tempdir().map_err(|e| {
214                Refusal::new(CommandStatus::Error, format!("no temporary directory: {e}"))
215            })?;
216            let file = dir.path().join("allowed_signers");
217            std::fs::write(&file, bytes).map_err(|e| {
218                Refusal::new(CommandStatus::Error, format!("cannot write the trust: {e}"))
219            })?;
220            held = Some(dir);
221            (Some(file), json!({ "rev": rev, "path": path }))
222        }
223    };
224    if allowed.is_none() && !sigstore {
225        if optional {
226            return Ok((
227                None,
228                json!({ "signatures": "not checked: no trust given" }),
229                None,
230            ));
231        }
232        return Err(Refusal::new(CommandStatus::UsageError, NO_TRUST));
233    }
234    let shown = json!({
235        "ssh_allowed_signers": source,
236        "gpg": true,
237        "sigstore": opts.identity.as_ref().map(|i| json!({ "identity": i, "issuer": opts.issuer })),
238    });
239    Ok((
240        Some(Trust {
241            allowed_signers: allowed,
242            identity: opts.identity.clone(),
243            issuer: opts.issuer.clone(),
244        }),
245        shown,
246        held,
247    ))
248}
249
250/// `path` relative to `root`, `/`-separated; `""` for the root itself.
251fn relative(path: &Path, root: &Path) -> Option<String> {
252    let canonical = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
253    let rel = canonical(path)
254        .strip_prefix(canonical(root))
255        .ok()?
256        .to_path_buf();
257    Some(rel.to_string_lossy().replace('\\', "/"))
258}
259
260/// `rel` as a prefix: `""`, or ending in `/`.
261fn as_prefix(rel: &str) -> String {
262    if rel.is_empty() {
263        String::new()
264    } else {
265        format!("{}/", rel.trim_end_matches('/'))
266    }
267}
268
269/// What the history says about tasks, read once: the latest commit
270/// carrying each `<unit>/<task>`'s verdict, and the trailer-only claims in
271/// the checked range.
272struct History {
273    latest: BTreeMap<String, String>,
274    bug_claims: BTreeSet<String>,
275}
276
277fn history(git: &Git, head: &str, base: Option<&str>) -> Result<History, Refusal> {
278    let log = git
279        .run(&[
280            "log",
281            "--grep=^Spec-Task: ",
282            "--format=%H%x1f%(trailers:key=Spec-Task,valueonly)%x1f%(trailers:key=Verdict,valueonly)%x1e",
283            head,
284        ])
285        .map(|b| String::from_utf8_lossy(&b).into_owned())
286        .map_err(|e| Refusal::internal(format!("git log {head}: {}", e.0)))?;
287    let in_range: Option<BTreeSet<String>> = base
288        .map(|b| {
289            git.run(&["rev-list", &format!("{b}..{head}")])
290                .map(|o| {
291                    String::from_utf8_lossy(&o)
292                        .lines()
293                        .map(str::to_owned)
294                        .collect()
295                })
296                .map_err(|e| Refusal::internal(format!("git rev-list {b}..{head}: {}", e.0)))
297        })
298        .transpose()?;
299    let mut latest = BTreeMap::new();
300    let mut bug_claims = BTreeSet::new();
301    for record in log.split(RS) {
302        let f: Vec<&str> = record.trim().split(FS).collect();
303        let [commit, task, verdict] = f[..] else {
304            continue;
305        };
306        let (task, verdict) = (task.trim(), verdict.trim());
307        if task.is_empty() || verdict.is_empty() {
308            continue;
309        }
310        if task.contains("/bug:") && in_range.as_ref().is_none_or(|r| r.contains(commit)) {
311            bug_claims.insert(task.to_owned());
312        }
313        latest
314            .entry(task.to_owned())
315            .or_insert_with(|| commit.to_owned());
316    }
317    Ok(History { latest, bug_claims })
318}
319
320/// Noted commits not reachable from `head`, by the task their
321/// task-verification names (`<unit>/<task>`): evidence a squash or rebase
322/// merge left behind.
323fn unreachable_evidence(
324    git: &Git,
325    repo: &Path,
326    head: &str,
327) -> Result<BTreeMap<String, String>, Refusal> {
328    let reachable: BTreeSet<String> = git
329        .run(&["rev-list", head])
330        .map(|o| {
331            String::from_utf8_lossy(&o)
332                .lines()
333                .map(str::to_owned)
334                .collect()
335        })
336        .map_err(|e| Refusal::internal(format!("git rev-list {head}: {}", e.0)))?;
337    let noted = notes::try_noted(repo)
338        .map_err(|e| Refusal::internal(format!("git notes list: {}", e.0)))?;
339    let mut out = BTreeMap::new();
340    for commit in noted {
341        if reachable.contains(&commit) {
342            continue;
343        }
344        for s in crate::evidence::statements(repo, &commit) {
345            if s["predicateType"] == statement::TASK_VERIFICATION
346                && let Some(name) = s["subject"][0]["name"].as_str()
347            {
348                out.insert(name.to_owned(), commit.clone());
349            }
350        }
351    }
352    Ok(out)
353}
354
355/// The changed paths between `base` and `head`. A diff git cannot make is
356/// an error: an empty list would touch no unit, and check nothing.
357fn changed(git: &Git, base: &str, head: &str) -> Result<Vec<String>, Refusal> {
358    git.run(&["diff", "--name-only", "-z", base, head])
359        .map(|o| crate::git::nul_fields(&o))
360        .map_err(|e| Refusal::internal(format!("git diff {base} {head}: {}", e.0)))
361}
362
363/// Whether `rev` holds a spec project at `start_rel` (read as the head is).
364fn project_at(top: &Path, rev: &str, start_rel: &str) -> Result<bool, Refusal> {
365    let dir = super::tree::intent_tops(top, rev)
366        .and_then(|tops| super::tree::materialize(top, rev, Some(&tops)))
367        .map_err(Refusal::internal)?;
368    let at = dir.path().join(start_rel);
369    Ok(crate::format::at(&at).inspect(&at, None).is_some())
370}
371
372/// One unit's ticks at the head.
373struct Unit {
374    name: String,
375    dir: String,
376    ticks: Vec<Tick>,
377    skipped: Vec<SkippedTask>,
378}
379
380/// One unit's ticks; a unit the project lists but this cannot read is an
381/// error, never a unit left out.
382fn unit(
383    format: &dyn Adapter,
384    project: &Path,
385    name: &str,
386    prefix: &str,
387    skips: &BTreeSet<String>,
388) -> Result<Unit, Refusal> {
389    let f = format
390        .inspect(project, Some(name))
391        .and_then(|snap| snap.feature)
392        .ok_or_else(|| Refusal::internal(format!("the unit {name} cannot be read at the head")))?;
393    let mut ticks = Vec::new();
394    let mut skipped = Vec::new();
395    if let Some(tasks) = &f.tasks {
396        let text = std::fs::read_to_string(project.join(&tasks.path))
397            .map_err(|e| Refusal::internal(format!("{prefix}{}: {e}", tasks.path)))?;
398        let lines: Vec<&str> = text.lines().collect();
399        for t in &tasks.tasks {
400            if !t.done {
401                if skips.contains(&format!("{name}/{}", t.id)) {
402                    skipped.push(SkippedTask {
403                        feature: name.to_owned(),
404                        task: t.id.clone(),
405                    });
406                }
407                continue;
408            }
409            let line = usize::try_from(t.line)
410                .ok()
411                .and_then(|l| l.checked_sub(1))
412                .and_then(|i| lines.get(i))
413                .copied()
414                .unwrap_or_default();
415            ticks.push(Tick {
416                feature: name.to_owned(),
417                task: t.id.clone(),
418                title: t.title.clone(),
419                kind: TickKind::Tick,
420                anchor: Anchor {
421                    path: format!("{prefix}{}", tasks.path),
422                    line: t.line,
423                },
424                line_sha256: Some(crate::integrity::sha256_hex(&crate::integrity::untick(
425                    line.as_bytes(),
426                ))),
427                claim_sha256: None,
428            });
429        }
430    }
431    Ok(Unit {
432        name: name.to_owned(),
433        dir: f.feature.dir,
434        ticks,
435        skipped,
436    })
437}
438
439/// The trailer-only task `claim` (`<unit>/bug:<slug>`) as the head holds
440/// it: anchored on its home's file (the first Expected line), its claim the
441/// triad's digest; `None` when the home cannot be read.
442fn trailer_only(format: &dyn Adapter, project: &Path, prefix: &str, claim: &str) -> Option<Tick> {
443    let (unit, task) = claim.rsplit_once('/')?;
444    // The claim comes from a commit's trailer (an untrusted pull request
445    // writes those): only a slug, and only the unit that slug's bug is.
446    let slug = task
447        .strip_prefix("bug:")
448        .filter(|s| speccoder_core::bug::valid_slug(s))?;
449    let home = format.bug_unit(project).ok().filter(|h| h.kind.native())?;
450    if format.bug_dir(&home, slug).map(|(_, u)| u).as_deref() != Some(unit) {
451        return None;
452    }
453    let read = format.read_bug(project, &home, slug).ok()?;
454    let triad = speccoder_core::bug::Triad::new(
455        read.current.first().map_or("", String::as_str),
456        &read.expected,
457        &read.unchanged,
458        crate::integrity::sha256_hex,
459    )
460    .ok()?;
461    let text = std::fs::read_to_string(project.join(&read.file)).unwrap_or_default();
462    let line = text
463        .lines()
464        .position(|l| l.contains("Expected:"))
465        .and_then(|i| u32::try_from(i + 1).ok())
466        .unwrap_or(1);
467    Some(Tick {
468        feature: unit.to_owned(),
469        task: task.to_owned(),
470        title: read.title.clone().unwrap_or_else(|| triad.title()),
471        kind: TickKind::TrailerOnly,
472        anchor: Anchor {
473            path: format!("{prefix}{}", read.file),
474            line,
475        },
476        line_sha256: None,
477        claim_sha256: Some(triad.digest),
478    })
479}
480
481/// The skipped tasks' refs, as `<unit>/<task>`.
482fn skips(git: &Git) -> Result<BTreeSet<String>, Refusal> {
483    git.run(&[
484        "for-each-ref",
485        "--format=%(refname)",
486        &format!("{}/", notes::SKIPPED),
487    ])
488    .map(|o| {
489        String::from_utf8_lossy(&o)
490            .lines()
491            .filter_map(|r| r.strip_prefix(&format!("{}/", notes::SKIPPED)))
492            // `bug:<slug>` is kept as `bug%3A<slug>` (no `:` in a ref).
493            .map(|r| r.replace("%3A", ":").replace("%25", "%"))
494            .collect()
495    })
496    .map_err(|e| Refusal::internal(format!("git for-each-ref {}: {}", notes::SKIPPED, e.0)))
497}
498
499/// A reason's line: `trailer Spec-Task: expected …, found …`.
500fn detail(v: &ObjectVerification) -> Option<String> {
501    let r = v.reasons.first()?;
502    Some(match (&r.field, &r.expected, &r.found) {
503        (Some(field), Some(e), Some(f)) => format!("{} {field}: expected {e}, found {f}", r.code),
504        (_, _, Some(f)) => format!("{}: {f}", r.code),
505        _ => r.code.clone(),
506    })
507}
508
509/// The tick's outcome from its commit's verification, the head's task line
510/// and the typed predicate.
511fn outcome_of(
512    tick: &Tick,
513    commit: &str,
514    v: ObjectVerification,
515    statements: &[Value],
516    require_signed: bool,
517) -> TickReport {
518    let mut r = TickReport {
519        tick: tick.clone(),
520        commit: Some(commit.to_owned()),
521        outcome: TickOutcome::Evidenced,
522        verdict: v.verdict.clone(),
523        reason: None,
524        detail: None,
525        signer: v.signer.clone(),
526        decider: None,
527        stale: Vec::new(),
528        stale_anchor: None,
529        regression: None,
530        newer_fields: Vec::new(),
531        verification: None,
532    };
533    let unverifiable = |r: &mut TickReport, reason: Unverifiable, detail: Option<String>| {
534        r.outcome = TickOutcome::Unverifiable;
535        r.reason = Some(reason);
536        r.detail = detail;
537    };
538    // A predicate of another major version is not one this verifier reads.
539    let other = statements.iter().find_map(|s| {
540        s["predicateType"]
541            .as_str()
542            .filter(|t| t.starts_with("https://speccoder.dev/task-verification/"))
543            .filter(|t| *t != statement::TASK_VERIFICATION)
544    });
545    let tv = statements
546        .iter()
547        .find(|s| s["predicateType"] == statement::TASK_VERIFICATION)
548        .map(|s| serde_json::from_value::<TaskVerificationV0>(s["predicate"].clone()));
549    match v.outcome {
550        VerifyOutcome::Ok => {}
551        VerifyOutcome::Unsigned if !require_signed => {}
552        o => {
553            let reason = Unverifiable::of(o).unwrap_or(Unverifiable::Malformed);
554            match other {
555                Some(t) if tv.is_none() => unverifiable(
556                    &mut r,
557                    Unverifiable::Malformed,
558                    Some(format!("predicate {t} not supported by this verifier")),
559                ),
560                _ => unverifiable(&mut r, reason, detail(&v)),
561            }
562        }
563    }
564    if r.outcome == TickOutcome::Evidenced {
565        match tv {
566            Some(Ok(tv)) => {
567                // A trailer-only task has no line: its claim is anchored on
568                // the bug's triad, as the verified predicate (the one the
569                // signature check covered) records it — never another
570                // envelope of the note.
571                let triad = tv
572                    .bug
573                    .as_ref()
574                    .and_then(|b| b["triad_sha256"].as_str().map(str::to_owned));
575                let agree = statements
576                    .iter()
577                    .filter(|s| s["predicateType"] == statement::TASK_VERIFICATION)
578                    .all(|s| s["predicate"]["bug"]["triad_sha256"].as_str() == triad.as_deref());
579                let anchored = match tick.kind {
580                    TickKind::Tick => Some(&tv.task.line_hash) == tick.line_sha256.as_ref(),
581                    TickKind::TrailerOnly => triad.is_some() && agree,
582                };
583                if tick.kind == TickKind::TrailerOnly && triad.is_some() && !agree {
584                    unverifiable(
585                        &mut r,
586                        Unverifiable::Mismatch,
587                        Some(
588                            "the note's task-verification statements name different triads"
589                                .to_owned(),
590                        ),
591                    );
592                } else if !anchored {
593                    unverifiable(
594                        &mut r,
595                        Unverifiable::Mismatch,
596                        Some("the task line changed since its tick: task.line_hash differs from the line at the head".to_owned()),
597                    );
598                }
599                // The triad read at the head against the one the fix was judged on.
600                if r.outcome == TickOutcome::Evidenced
601                    && tick.kind == TickKind::TrailerOnly
602                    && triad.as_deref() != tick.claim_sha256.as_deref()
603                {
604                    r.outcome = TickOutcome::Stale;
605                    r.stale_anchor = Some(tick.anchor.clone());
606                    r.detail = Some(
607                        "the bug's triad changed since its fix: the verdict is for the earlier words"
608                            .to_owned(),
609                    );
610                }
611                r.newer_fields = tv.newer_fields();
612                r.regression = tv
613                    .regression
614                    .as_ref()
615                    .map(speccoder_core::regression::wording);
616                r.decider = tv.decider.map(|d| d.by);
617            }
618            Some(Err(e)) => unverifiable(
619                &mut r,
620                Unverifiable::Malformed,
621                Some(format!("the predicate is not task-verification v0: {e}")),
622            ),
623            None => unverifiable(
624                &mut r,
625                Unverifiable::Mismatch,
626                Some("no task-verification statement".to_owned()),
627            ),
628        }
629    }
630    if v.outcome == VerifyOutcome::Unsigned && require_signed {
631        r.detail =
632            Some("the statements are not signed (sign with `speccoder attest sign`)".to_owned());
633    }
634    r.verification = Some(v);
635    r
636}
637
638/// A tick no reachable commit carries evidence for.
639fn without_commit(tick: &Tick, lost: Option<&String>) -> TickReport {
640    let (reason, detail) = match lost {
641        Some(c) => (
642            Unverifiable::CommitUnreachable,
643            format!(
644                "its evidence is on {} — not reachable from the head (a squash or rebase merge?); merge with a merge commit to keep it",
645                &c[..c.len().min(12)]
646            ),
647        ),
648        None => (
649            Unverifiable::TickedWithoutEvidence,
650            "no commit carries this task's verdict (ticked by hand?)".to_owned(),
651        ),
652    };
653    TickReport {
654        tick: tick.clone(),
655        commit: None,
656        outcome: TickOutcome::Unverifiable,
657        verdict: None,
658        reason: Some(reason),
659        detail: Some(detail),
660        signer: None,
661        decider: None,
662        stale: Vec::new(),
663        stale_anchor: None,
664        regression: None,
665        newer_fields: Vec::new(),
666        verification: None,
667    }
668}
669
670/// The 1-based line of `text` holding `line` (trimmed), if any.
671fn line_of(text: &str, line: &str) -> Option<u32> {
672    let want = line.trim();
673    text.lines()
674        .position(|l| l.trim() == want)
675        .and_then(|i| u32::try_from(i + 1).ok())
676}
677
678/// Marks evidenced ticks whose cited requirements changed between their
679/// commit and the head as stale.
680fn mark_stale(
681    top: &Path,
682    format: &dyn Adapter,
683    project: &Path,
684    prefix: &str,
685    unit_dir: &str,
686    head: &str,
687    reports: &mut [TickReport],
688) {
689    let asked: Vec<(&str, Option<&str>)> = reports
690        .iter()
691        .map(|r| {
692            (
693                r.tick.title.as_str(),
694                r.commit
695                    .as_deref()
696                    .filter(|_| r.outcome == TickOutcome::Evidenced),
697            )
698        })
699        .collect();
700    let Some(found) = crate::drift::at(top, format, unit_dir, prefix, head, &asked) else {
701        return;
702    };
703    let spec = format
704        .requirements_file()
705        .map(|f| format!("{}/{f}", unit_dir.trim_end_matches('/')));
706    let spec_text = spec
707        .as_ref()
708        .and_then(|s| std::fs::read_to_string(project.join(s)).ok())
709        .unwrap_or_default();
710    for (r, drift) in reports.iter_mut().zip(found) {
711        let Some(drift) = drift.filter(|d| !d.requirements.is_empty()) else {
712            continue;
713        };
714        r.outcome = TickOutcome::Stale;
715        r.stale_anchor = drift.requirements.iter().find_map(|d| {
716            let now = d.now.as_deref()?;
717            Some(Anchor {
718                path: format!("{prefix}{}", spec.as_deref()?),
719                line: line_of(&spec_text, now)?,
720            })
721        });
722        r.detail = Some(format!(
723            "{} changed since the tick: the verdict is for the earlier wording",
724            drift
725                .requirements
726                .iter()
727                .map(|d| d.id.as_str())
728                .collect::<Vec<_>>()
729                .join(", ")
730        ));
731        r.stale = drift.requirements;
732    }
733}
734
735/// The word a trace row shows for a tick.
736fn word(r: &TickReport) -> String {
737    match r.outcome {
738        TickOutcome::Evidenced => r.verdict.clone().unwrap_or_default(),
739        TickOutcome::Stale => "stale".to_owned(),
740        TickOutcome::Unverifiable => "unverifiable".to_owned(),
741        TickOutcome::Regressed => "regressed".to_owned(),
742    }
743}
744
745/// Requirement → task → verdict rows for one unit (a requirement no tick
746/// cites is a row of its own), then the scenario/test rows the signed
747/// `trace[]` of each evidenced tick holds.
748fn trace(
749    requirements: &[(String, String)],
750    reports: &[TickReport],
751    signed: &BTreeMap<String, Vec<Value>>,
752) -> Vec<TraceRow> {
753    let mut rows = Vec::new();
754    for (id, _) in requirements {
755        let citing: Vec<&TickReport> = reports
756            .iter()
757            .filter(|r| super::cites(&r.tick.title, id))
758            .collect();
759        if citing.is_empty() {
760            rows.push(TraceRow {
761                requirement: id.clone(),
762                scenario: None,
763                test: None,
764                task: None,
765                verdict: None,
766                changed: false,
767                inherited: None,
768            });
769        }
770        for r in citing {
771            rows.push(TraceRow {
772                requirement: id.clone(),
773                scenario: None,
774                test: None,
775                task: Some(r.tick.task.clone()),
776                verdict: Some(word(r)),
777                changed: r.stale.iter().any(|d| d.id == *id),
778                inherited: None,
779            });
780        }
781    }
782    for r in reports {
783        for row in signed.get(&r.tick.task).into_iter().flatten() {
784            let Some(test) = row["test"].as_str() else {
785                continue;
786            };
787            rows.push(TraceRow {
788                requirement: row["requirement"].as_str().unwrap_or("—").to_owned(),
789                scenario: row["scenario"].as_str().map(str::to_owned),
790                test: Some(test.to_owned()),
791                task: Some(r.tick.task.clone()),
792                verdict: Some(word(r)),
793                changed: false,
794                inherited: None,
795            });
796        }
797    }
798    rows
799}
800
801/// Checks the ticks of the units the branch touches.
802///
803/// # Errors
804///
805/// Not a git repository, a revision that does not resolve, or no trust.
806pub fn check(start: &Path, opts: &Options) -> Result<Checked, Refusal> {
807    run(start, opts, None)
808}
809
810/// One task of `feature` at the head, for its tour: checked with the
811/// caller's trust when given, else read as recorded (signatures not
812/// checked, said in its detail). The report holds that tick alone, or none.
813///
814/// # Errors
815///
816/// As [`check`], except that no trust is no refusal.
817pub fn locate(start: &Path, opts: &Options, feature: &str, task: &str) -> Result<Checked, Refusal> {
818    let opts = Options {
819        all: true,
820        base: None,
821        ..opts.clone()
822    };
823    run(start, &opts, Some((feature, Some(task))))
824}
825
826/// Every tick of `feature` at `head`, read as recorded (no trust, no
827/// signature checked): what `pr`'s body tours show before a maintainer's
828/// verifier checks them.
829///
830/// # Errors
831///
832/// Not a git repository, or `head` does not resolve.
833pub fn recorded(start: &Path, head: &str, feature: &str) -> Result<Checked, Refusal> {
834    let opts = Options {
835        head: head.to_owned(),
836        all: true,
837        ..Options::default()
838    };
839    run(start, &opts, Some((feature, None)))
840}
841
842/// The tasks of `feature` closed on `head`: ticked there, with the commit
843/// reachable from it that carries the task's verdict — `(task, commit)`.
844/// What `pr` publishes held-out sets for; read as recorded, no trust.
845#[must_use]
846pub fn closed(start: &Path, head: &str, feature: &str) -> Vec<(String, String)> {
847    recorded(start, head, feature)
848        .map(|c| {
849            c.report
850                .ticks
851                .into_iter()
852                .filter_map(|t| t.commit.map(|c| (t.tick.task, c)))
853                .collect()
854        })
855        .unwrap_or_default()
856}
857
858/// The statements' own word, when there is no trust to check signatures
859/// with: what [`super::verify::object`] would say short of them.
860fn as_recorded(commit: &str, task: &str, statements: &[Value]) -> ObjectVerification {
861    let tv = statements
862        .iter()
863        .find(|s| s["predicateType"] == statement::TASK_VERIFICATION);
864    ObjectVerification {
865        object: commit.to_owned(),
866        kind: "task".to_owned(),
867        task: task.to_owned(),
868        verdict: tv.and_then(|s| s["predicate"]["verdict"].as_str().map(str::to_owned)),
869        outcome: if statements.is_empty() {
870            VerifyOutcome::Missing
871        } else {
872            VerifyOutcome::Ok
873        },
874        reasons: Vec::new(),
875        signer: None,
876        statements: statements.len(),
877        same_tree_as: None,
878    }
879}
880
881#[allow(clippy::too_many_lines)] // one pass, in the report's order
882fn run(
883    start: &Path,
884    opts: &Options,
885    only: Option<(&str, Option<&str>)>,
886) -> Result<Checked, Refusal> {
887    let top =
888        Git::at(start).toplevel().ok().flatten().ok_or_else(|| {
889            Refusal::new(CommandStatus::GitPrecondition, "not in a git repository")
890        })?;
891    let git = Git::at(&top);
892    let resolve = |rev: &str| {
893        git.line(&[
894            "rev-parse",
895            "--verify",
896            "--quiet",
897            &format!("{rev}^{{commit}}"),
898        ])
899        .ok()
900        .filter(|c| !c.is_empty())
901        .ok_or_else(|| {
902            Refusal::new(
903                CommandStatus::GitPrecondition,
904                format!("{rev} is not a commit here (fetch it first)"),
905            )
906        })
907    };
908    let head = resolve(&opts.head)?;
909    let base = match opts.base.as_deref() {
910        Some(b) => {
911            let b = resolve(b)?;
912            Some(
913                git.line(&["merge-base", &b, &head])
914                    .ok()
915                    .filter(|m| !m.is_empty())
916                    .ok_or_else(|| {
917                        Refusal::new(
918                            CommandStatus::GitPrecondition,
919                            format!("{b} and {head} share no history"),
920                        )
921                    })?,
922            )
923        }
924        None => None,
925    };
926    let (trust, trust_shown, _held) = trust(&top, opts, only.is_some())?;
927    let mode = if opts.all || base.is_none() {
928        Mode::All
929    } else {
930        Mode::Touched
931    };
932    let mut report = BranchReport {
933        head: head.clone(),
934        base: base.clone(),
935        mode,
936        features: Vec::new(),
937        ticks: Vec::new(),
938        skipped: Vec::new(),
939        totals: BranchTotals::default(),
940        conclusion: Conclusion::Neutral,
941        trust: trust_shown,
942        accepted_unsigned: !opts.require_signed,
943        require_verified: opts.require_verified,
944        trace: Vec::new(),
945        share: None,
946        open_no_claim: Vec::new(),
947        not_evaluated: vec![verify_branch::EARLIER_TASK_REGRESSIONS.to_owned()],
948        newer_fields: Vec::new(),
949    };
950    let history = history(
951        &git,
952        &head,
953        base.as_deref().filter(|_| mode == Mode::Touched),
954    )?;
955    let start_rel = relative(start, &top).unwrap_or_default();
956    let dir = super::tree::intent_tops(&top, &head)
957        .and_then(|tops| super::tree::materialize(&top, &head, Some(&tops)))
958        .map_err(Refusal::internal)?;
959    let opened = {
960        let at = dir.path().join(&start_rel);
961        let format = crate::format::at(&at);
962        format.inspect(&at, None).map(|snap| (format, snap))
963    };
964    let Some((format, snap)) = opened else {
965        // Nothing to verify only when there is no project to verify: a head
966        // that drops the project its base (or the trust's revision) holds
967        // has not shown there is nothing to check.
968        let reference = base.clone().or_else(|| match &opts.allowed_signers {
969            Some(AllowedSigners::Rev { rev, .. }) => resolve(rev).ok(),
970            _ => None,
971        });
972        if let Some(r) = reference
973            && project_at(&top, &r, &start_rel)?
974        {
975            return Err(Refusal::new(
976                CommandStatus::AttestationFailed,
977                format!(
978                    "{head} holds no spec project where {r} holds one: the head removes or breaks it, and its ticks cannot be checked"
979                ),
980            ));
981        }
982        report.conclusion = Conclusion::of(&[], opts.require_verified, &report.not_evaluated);
983        return Ok(Checked {
984            report,
985            repo: top,
986            prefix: as_prefix(&start_rel),
987        });
988    };
989    let project = PathBuf::from(&snap.root);
990    let prefix = as_prefix(&relative(&project, dir.path()).unwrap_or_default());
991    let mut units: Vec<&speccoder_core::snapshot::FeatureRef> = snap
992        .features
993        .iter()
994        .filter(|f| only.is_none_or(|(name, _)| f.name == name))
995        .collect();
996    if mode == Mode::Touched
997        && let Some(b) = &base
998    {
999        let paths = changed(&git, b, &head)?;
1000        let dirs: Vec<String> = units
1001            .iter()
1002            .map(|f| format!("{prefix}{}/", f.dir.trim_end_matches('/')))
1003            .collect();
1004        let policy = crate::tools::policy::Policy::new(&[], &[]).rules(format.rules());
1005        // A shared intent file (a constitution) speaks for every unit.
1006        let shared = paths.iter().any(|p| {
1007            p.strip_prefix(&prefix).is_some_and(|rel| {
1008                !dirs.iter().any(|d| p.starts_with(d))
1009                    && policy.write_protected(rel) == Some(crate::tools::paths::Class::Intent)
1010            })
1011        });
1012        if !shared {
1013            units = units
1014                .into_iter()
1015                .zip(&dirs)
1016                .filter(|(_, d)| paths.iter().any(|p| p.starts_with(d.as_str())))
1017                .map(|(f, _)| f)
1018                .collect();
1019        }
1020    }
1021    let skipped = skips(&git)?;
1022    let config_path = crate::verify::find_config(&project)
1023        .and_then(|d| relative(&d, dir.path()))
1024        .map_or_else(
1025            || format!("{prefix}{}", crate::verify::config::FILE),
1026            |d| format!("{}{}", as_prefix(&d), crate::verify::config::FILE),
1027        );
1028    let mut lost: Option<BTreeMap<String, String>> = None;
1029    for f in units {
1030        let mut u = unit(format, &project, &f.name, &prefix, &skipped)?;
1031        if let Some((_, Some(task))) = only {
1032            u.ticks.retain(|t| t.task == task);
1033        }
1034        report.features.push(u.name.clone());
1035        report.skipped.extend(u.skipped);
1036        let tasks_path = u.ticks.first().map(|t| t.anchor.path.clone());
1037        let unchecked = Trust::default();
1038        let cx = Context {
1039            repo: &top,
1040            feature: &u.name,
1041            tasks_path: tasks_path.as_deref(),
1042            config_path: &config_path,
1043            trust: trust.as_ref().unwrap_or(&unchecked),
1044            format: Some(format),
1045        };
1046        let checking = trust.is_some();
1047        // Where the evidence of a tick no reachable commit carries went:
1048        // read once, before the checks, so a git failure is an error.
1049        if lost.is_none()
1050            && u.ticks.iter().any(|t| {
1051                !history
1052                    .latest
1053                    .contains_key(&format!("{}/{}", t.feature, t.task))
1054            })
1055        {
1056            lost = Some(unreachable_evidence(&git, &top, &head)?);
1057        }
1058        // Each commit's check is its own processes; a few side by side.
1059        let width = std::thread::available_parallelism().map_or(4, std::num::NonZero::get);
1060        let mut reports: Vec<TickReport> = Vec::new();
1061        let mut signed: BTreeMap<String, Vec<Value>> = BTreeMap::new();
1062        for chunk in u.ticks.chunks(width.max(1)) {
1063            let done: Vec<(TickReport, Vec<Value>)> = std::thread::scope(|scope| {
1064                let handles: Vec<_> = chunk
1065                    .iter()
1066                    .map(|tick| {
1067                        let commit = history
1068                            .latest
1069                            .get(&format!("{}/{}", tick.feature, tick.task))
1070                            .cloned();
1071                        let cx = &cx;
1072                        scope.spawn(move || {
1073                            let commit = commit?;
1074                            let statements = crate::evidence::statements(cx.repo, &commit);
1075                            if !checking {
1076                                let v = as_recorded(&commit, &tick.task, &statements);
1077                                let mut r = outcome_of(tick, &commit, v, &statements, false);
1078                                if r.outcome == TickOutcome::Evidenced {
1079                                    r.detail = Some(
1080                                        "as recorded: signatures not checked (no trust given)"
1081                                            .to_owned(),
1082                                    );
1083                                }
1084                                return Some((r, statements));
1085                            }
1086                            let v = super::verify::object(
1087                                cx,
1088                                &Target {
1089                                    object: commit.clone(),
1090                                    kind: "task",
1091                                    task: tick.task.clone(),
1092                                },
1093                            );
1094                            Some((
1095                                outcome_of(tick, &commit, v, &statements, opts.require_signed),
1096                                statements,
1097                            ))
1098                        })
1099                    })
1100                    .collect();
1101                handles
1102                    .into_iter()
1103                    .zip(chunk)
1104                    .map(|(h, tick)| match h.join() {
1105                        Ok(Some(done)) => done,
1106                        Ok(None) => (
1107                            without_commit(
1108                                tick,
1109                                lost.as_ref().and_then(|l| {
1110                                    l.get(&format!("{}/{}", tick.feature, tick.task))
1111                                }),
1112                            ),
1113                            Vec::new(),
1114                        ),
1115                        Err(_) => {
1116                            let mut r = without_commit(tick, None);
1117                            r.reason = Some(Unverifiable::Malformed);
1118                            r.detail = Some("the check panicked".to_owned());
1119                            (r, Vec::new())
1120                        }
1121                    })
1122                    .collect()
1123            });
1124            for (r, statements) in done {
1125                let rows: Vec<Value> = statements
1126                    .iter()
1127                    .filter(|s| s["predicateType"] == statement::TASK_VERIFICATION)
1128                    .flat_map(|s| {
1129                        s["predicate"]["trace"]
1130                            .as_array()
1131                            .cloned()
1132                            .unwrap_or_default()
1133                    })
1134                    .collect();
1135                signed.insert(r.tick.task.clone(), rows);
1136                reports.push(r);
1137            }
1138        }
1139        mark_stale(&top, format, &project, &prefix, &u.dir, &head, &mut reports);
1140        let requirements = format.requirement_ids(&project, &u.dir);
1141        report.trace.extend(trace(&requirements, &reports, &signed));
1142        report.ticks.extend(reports);
1143    }
1144    // Trailer-only tasks: a bug of the format's own bug structure, closed by
1145    // its verdict commit and anchored on its triad.
1146    for claim in &history.bug_claims {
1147        let Some(tick) = trailer_only(format, &project, &prefix, claim) else {
1148            // A claim whose home this build cannot read is not proof.
1149            if !report
1150                .not_evaluated
1151                .iter()
1152                .any(|n| n == verify_branch::TRAILER_ONLY_TASKS)
1153            {
1154                report
1155                    .not_evaluated
1156                    .push(verify_branch::TRAILER_ONLY_TASKS.to_owned());
1157            }
1158            continue;
1159        };
1160        if only.is_some_and(|(name, task)| {
1161            tick.feature != name || task.is_some_and(|t| t != tick.task)
1162        }) {
1163            continue;
1164        }
1165        let Some(commit) = history.latest.get(claim).cloned() else {
1166            continue;
1167        };
1168        let statements = crate::evidence::statements(&top, &commit);
1169        let unchecked = Trust::default();
1170        let cx = Context {
1171            repo: &top,
1172            feature: &tick.feature,
1173            tasks_path: None,
1174            config_path: &config_path,
1175            trust: trust.as_ref().unwrap_or(&unchecked),
1176            format: Some(format),
1177        };
1178        let r = if trust.is_some() {
1179            let v = super::verify::object(
1180                &cx,
1181                &Target {
1182                    object: commit.clone(),
1183                    kind: "task",
1184                    task: tick.task.clone(),
1185                },
1186            );
1187            outcome_of(&tick, &commit, v, &statements, opts.require_signed)
1188        } else {
1189            let v = as_recorded(&commit, &tick.task, &statements);
1190            let mut r = outcome_of(&tick, &commit, v, &statements, false);
1191            if r.outcome == TickOutcome::Evidenced {
1192                r.detail = Some("as recorded: signatures not checked (no trust given)".to_owned());
1193            }
1194            r
1195        };
1196        if !report.features.contains(&tick.feature) {
1197            report.features.push(tick.feature.clone());
1198        }
1199        report.ticks.push(r);
1200    }
1201    let mut newer: BTreeSet<String> = BTreeSet::new();
1202    for t in &report.ticks {
1203        newer.extend(t.newer_fields.iter().cloned());
1204    }
1205    report.newer_fields = newer.into_iter().collect();
1206    report.totals = BranchTotals::of(&report.ticks, report.skipped.len());
1207    report.share = verify_branch::share(&report.ticks);
1208    report.conclusion = Conclusion::of(&report.ticks, opts.require_verified, &report.not_evaluated);
1209    Ok(Checked {
1210        report,
1211        repo: top,
1212        prefix,
1213    })
1214}
1215
1216/// Where `attest verify --branch` writes the check besides its result.
1217#[derive(Debug, Clone, Default)]
1218pub struct Outputs {
1219    /// The check as Markdown, to this file.
1220    pub markdown: Option<PathBuf>,
1221    /// The check-run request bodies (`{create, updates}`), to this file.
1222    pub github_check: Option<PathBuf>,
1223    /// The check run's name ([`super::check::NAME`] when empty).
1224    pub check_name: String,
1225    /// A neutral outcome is published as `neutral` (else `failure`).
1226    pub allow_neutral: bool,
1227    /// The workflow-command annotations, and the check appended to
1228    /// `$GITHUB_STEP_SUMMARY` (where the check cannot be published).
1229    pub annotations: bool,
1230}
1231
1232/// A published check: the result and the workflow-command lines to print.
1233#[derive(Debug)]
1234pub struct Published {
1235    /// The result (payload `branch_verify`, or a refusal).
1236    pub result: CommandResult,
1237    /// With [`Outputs::annotations`]: the lines for the job's log.
1238    pub workflow_commands: Vec<String>,
1239}
1240
1241/// [`verify`], and the check written where `out` asks. A file that cannot
1242/// be written makes the result an error (the report stays its payload).
1243#[must_use]
1244pub fn verify_and_publish(start: &Path, opts: &Options, out: &Outputs, command: &str) -> Published {
1245    let checked = match check(start, opts) {
1246        Ok(c) => c,
1247        Err(r) => {
1248            return Published {
1249                result: CommandResult::new(command, r.status, &r.message),
1250                workflow_commands: Vec::new(),
1251            };
1252        }
1253    };
1254    // Tours only where they are shown: each reads its commit's tree.
1255    let tours = if out.markdown.is_some() || out.github_check.is_some() || out.annotations {
1256        crate::tour::markdown_all(&checked)
1257    } else {
1258        Vec::new()
1259    };
1260    let report = &checked.report;
1261    let mut failed = Vec::new();
1262    if let Some(path) = &out.markdown
1263        && let Err(e) = write(path, &super::check::markdown(report, &tours))
1264    {
1265        failed.push(e);
1266    }
1267    if let Some(path) = &out.github_check {
1268        let name = if out.check_name.is_empty() {
1269            super::check::NAME
1270        } else {
1271            &out.check_name
1272        };
1273        let body = super::check::request(report, &tours, name, out.allow_neutral);
1274        if let Err(e) = write(path, &format!("{body}\n")) {
1275            failed.push(e);
1276        }
1277    }
1278    let mut workflow_commands = Vec::new();
1279    if out.annotations {
1280        workflow_commands = super::check::workflow_commands(report);
1281        if let Some(summary) = std::env::var_os("GITHUB_STEP_SUMMARY") {
1282            use std::io::Write as _;
1283            let appended = std::fs::OpenOptions::new()
1284                .append(true)
1285                .create(true)
1286                .open(&summary)
1287                .and_then(|mut f| writeln!(f, "{}", super::check::markdown(report, &tours)));
1288            if let Err(e) = appended {
1289                failed.push(format!("the step summary was not written: {e}"));
1290            }
1291        }
1292    }
1293    let result = result(command, checked.report);
1294    let result = if failed.is_empty() {
1295        result
1296    } else {
1297        failed_writes(command, result, &failed)
1298    };
1299    Published {
1300        result,
1301        workflow_commands,
1302    }
1303}
1304
1305/// `attest badge`: the share of ticks with verifying evidence over every
1306/// unit, as an SVG and a shields.io endpoint. A measurement: `ok` whenever
1307/// it was written, whatever the share.
1308#[must_use]
1309pub fn badge(
1310    start: &Path,
1311    opts: &Options,
1312    svg: Option<&Path>,
1313    endpoint: Option<&Path>,
1314    command: &str,
1315) -> CommandResult {
1316    let opts = Options {
1317        all: true,
1318        ..opts.clone()
1319    };
1320    let checked = match check(start, &opts) {
1321        Ok(c) => c,
1322        Err(r) => return CommandResult::new(command, r.status, &r.message),
1323    };
1324    let badge = verify_branch::Badge::of(&checked.report);
1325    let mut failed = Vec::new();
1326    if let Some(path) = svg
1327        && let Err(e) = write(path, &badge.svg())
1328    {
1329        failed.push(e);
1330    }
1331    if let Some(path) = endpoint {
1332        let json = serde_json::to_string_pretty(&badge).unwrap_or_default();
1333        if let Err(e) = write(path, &format!("{json}\n")) {
1334            failed.push(e);
1335        }
1336    }
1337    let result = CommandResult::new(
1338        command,
1339        CommandStatus::Ok,
1340        &format!("evidence: {} ({})", badge.message, badge.color),
1341    )
1342    .with_data(CommandData::BranchVerify(Box::new(checked.report)));
1343    if failed.is_empty() {
1344        result
1345    } else {
1346        failed_writes(command, result, &failed)
1347    }
1348}
1349
1350fn write(path: &Path, text: &str) -> Result<(), String> {
1351    std::fs::write(path, text).map_err(|e| format!("cannot write {}: {e}", path.display()))
1352}
1353
1354/// `result` as an error naming the files that were not written.
1355fn failed_writes(command: &str, result: CommandResult, failed: &[String]) -> CommandResult {
1356    let message = format!("{}; {}", result.message(), failed.join("; "));
1357    let shown = CommandResult::new(command, CommandStatus::Error, &message);
1358    match result.data() {
1359        Some(CommandData::BranchVerify(r)) => shown.with_data(CommandData::BranchVerify(r.clone())),
1360        _ => shown,
1361    }
1362}
1363
1364#[cfg(test)]
1365mod tests {
1366    use super::*;
1367
1368    #[test]
1369    fn a_trust_revision_needs_both_parts() {
1370        assert!(matches!(
1371            AllowedSigners::parse_rev("origin/main:.github/speccoder/allowed_signers"),
1372            Ok(AllowedSigners::Rev { rev, path }) if rev == "origin/main" && path == ".github/speccoder/allowed_signers"
1373        ));
1374        assert!(AllowedSigners::parse_rev("main").is_err());
1375        assert!(AllowedSigners::parse_rev(":x").is_err());
1376    }
1377
1378    #[test]
1379    fn the_line_of_a_requirement_is_found_trimmed() {
1380        let text = "# Spec\n\n- **FR-001**: Users MUST log in.\n";
1381        assert_eq!(
1382            line_of(text, "  - **FR-001**: Users MUST log in. "),
1383            Some(3)
1384        );
1385        assert_eq!(line_of(text, "nope"), None);
1386    }
1387
1388    #[test]
1389    fn a_git_failure_is_an_error_never_an_empty_answer() {
1390        let dir = tempfile::tempdir().unwrap();
1391        let run = |args: &[&str]| {
1392            assert!(
1393                std::process::Command::new("git")
1394                    .args(["-c", "user.name=t", "-c", "user.email=t@example.com"])
1395                    .args(["-c", "commit.gpgsign=false"])
1396                    .args(args)
1397                    .current_dir(dir.path())
1398                    .output()
1399                    .unwrap()
1400                    .status
1401                    .success()
1402            );
1403        };
1404        run(&["init", "-q"]);
1405        run(&["commit", "-q", "--allow-empty", "-m", "x"]);
1406        let git = Git::at(dir.path());
1407        // An empty diff would touch no unit and check nothing.
1408        let Err(r) = changed(&git, "no-such-base", "HEAD") else {
1409            panic!("a failed diff was an empty one");
1410        };
1411        assert_eq!(r.status, CommandStatus::Error);
1412        assert!(history(&git, "no-such-head", None).is_err());
1413        assert!(history(&git, "HEAD", Some("no-such-base")).is_err());
1414        assert!(unreachable_evidence(&git, dir.path(), "no-such-head").is_err());
1415        assert!(changed(&git, "HEAD", "HEAD").unwrap().is_empty());
1416    }
1417
1418    #[test]
1419    fn no_trust_is_refused_even_when_unsigned_ticks_are_accepted() {
1420        let dir = tempfile::tempdir().unwrap();
1421        let opts = Options {
1422            require_signed: false,
1423            ..Options::default()
1424        };
1425        let Err(r) = trust(dir.path(), &opts, false) else {
1426            panic!("trusted nothing")
1427        };
1428        assert_eq!(r.message, NO_TRUST);
1429    }
1430}