Skip to main content

speccoder_core/
verify_branch.rs

1//! A branch's ticks and what their evidence comes to: the payload of
2//! `speccoder attest verify --branch` (and `attest badge`)
3//! (`branch_verify`), the rule that turns per-tick outcomes into the
4//! branch's conclusion, and the badge.
5//!
6//! The rules are here, pure, so every interface (the binary, the check
7//! run, the badge) concludes the same from the same ticks.
8
9use serde::Serialize;
10
11use crate::{ObjectVerification, RequirementDrift, SignerView, TraceRow, VerifyOutcome};
12
13/// `not_evaluated`: earlier-task regressions have no signed record
14/// this verifier could read yet.
15pub const EARLIER_TASK_REGRESSIONS: &str = "earlier_task_regressions";
16/// `not_evaluated`: a trailer-only bug task (`Spec-Task: <unit>/bug:<slug>`)
17/// is in the checked range, and this version cannot check it.
18pub const TRAILER_ONLY_TASKS: &str = "trailer_only_tasks";
19
20/// Where an annotation lands: a file and a 1-based line, at the head.
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
22pub struct Anchor {
23    /// Repository-relative path.
24    pub path: String,
25    /// 1-based line.
26    pub line: u32,
27}
28
29/// What a checked task is.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
31#[serde(rename_all = "snake_case")]
32pub enum TickKind {
33    /// A `[X]` line in a task list.
34    Tick,
35    /// A closed task of a format home without a task list: its verdict
36    /// commit is the claim (a bug's `bug:<slug>`).
37    TrailerOnly,
38}
39
40/// A ticked task at the checked head.
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
42pub struct Tick {
43    /// The unit, as the format adapter names it.
44    pub feature: String,
45    /// Task ID (`T012`, `1.3`, `bug:<slug>`).
46    pub task: String,
47    /// The task's title.
48    pub title: String,
49    /// A tick or a trailer-only task.
50    pub kind: TickKind,
51    /// The task line (a tick) or the home's assessment (trailer-only).
52    pub anchor: Anchor,
53    /// sha256 of the task line with its box read as open: what the
54    /// predicate's `task.line_hash` must equal. `None` for trailer-only.
55    pub line_sha256: Option<String>,
56    /// For trailer-only: the claim's digest at the head (the bug's triad).
57    pub claim_sha256: Option<String>,
58}
59
60/// Why a tick's evidence does not verify.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
62#[serde(rename_all = "snake_case")]
63pub enum Unverifiable {
64    /// No commit carries the task's verdict (ticked by hand).
65    TickedWithoutEvidence,
66    /// The task commit has no attestation note.
67    Missing,
68    /// A line of the note does not parse, or the predicate is not v0.
69    Malformed,
70    /// A signature does not verify.
71    BadSignature,
72    /// A statement disagrees with the commit, its trailers, the task line
73    /// or the bundle.
74    Mismatch,
75    /// Signed by a key the verifier does not trust.
76    UntrustedSigner,
77    /// Not signed (unverifiable unless the caller accepts unsigned ticks).
78    Unsigned,
79    /// The commit the evidence names is not reachable from the head (a
80    /// squash or rebase merge).
81    CommitUnreachable,
82    /// The signature's tool (`ssh-keygen`, `gpg`, `cosign`) cannot run.
83    ToolMissing,
84}
85
86impl Unverifiable {
87    /// The reason an `attest verify` outcome is, `None` for `ok`.
88    #[must_use]
89    pub const fn of(outcome: VerifyOutcome) -> Option<Self> {
90        match outcome {
91            VerifyOutcome::Ok => None,
92            VerifyOutcome::Unsigned => Some(Self::Unsigned),
93            VerifyOutcome::Missing => Some(Self::Missing),
94            VerifyOutcome::BadSignature => Some(Self::BadSignature),
95            VerifyOutcome::UntrustedSigner => Some(Self::UntrustedSigner),
96            VerifyOutcome::Mismatch => Some(Self::Mismatch),
97            VerifyOutcome::Malformed => Some(Self::Malformed),
98            VerifyOutcome::ToolMissing => Some(Self::ToolMissing),
99        }
100    }
101
102    /// The word reports use.
103    #[must_use]
104    pub const fn word(self) -> &'static str {
105        match self {
106            Self::TickedWithoutEvidence => "ticked_without_evidence",
107            Self::Missing => "missing",
108            Self::Malformed => "malformed",
109            Self::BadSignature => "bad_signature",
110            Self::Mismatch => "mismatch",
111            Self::UntrustedSigner => "untrusted_signer",
112            Self::Unsigned => "unsigned",
113            Self::CommitUnreachable => "commit_unreachable",
114            Self::ToolMissing => "tool_missing",
115        }
116    }
117}
118
119/// A tick's outcome.
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
121#[serde(rename_all = "snake_case")]
122pub enum TickOutcome {
123    /// The evidence verifies; `verdict` says what it shows.
124    Evidenced,
125    /// The evidence verifies, but a requirement the task cites changed
126    /// since the tick: it is for the earlier wording.
127    Stale,
128    /// The evidence does not verify; `reason` says why.
129    Unverifiable,
130    /// Reserved: a later signed checkpoint marks this earlier task
131    /// regressed. No producer yet; it fails like `unverifiable`.
132    Regressed,
133}
134
135/// One tick's report.
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
137pub struct TickReport {
138    /// The tick.
139    pub tick: Tick,
140    /// The task commit checked (the latest reachable with the task's
141    /// verdict trailers), when one exists.
142    pub commit: Option<String>,
143    /// The outcome.
144    pub outcome: TickOutcome,
145    /// `verified`, `regression_only` or `attested`, as the evidence claims.
146    pub verdict: Option<String>,
147    /// For `unverifiable`.
148    pub reason: Option<Unverifiable>,
149    /// More on the reason, in one line.
150    pub detail: Option<String>,
151    /// Who signed.
152    pub signer: Option<SignerView>,
153    /// Who accepted an `attested` tick.
154    pub decider: Option<String>,
155    /// For `stale`: the cited requirements that changed since the tick.
156    pub stale: Vec<RequirementDrift>,
157    /// For `stale`: the first changed requirement's line at the head.
158    pub stale_anchor: Option<Anchor>,
159    /// A regression-only tick: why, in speccoder's wording (signed).
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub regression: Option<String>,
162    /// Predicate fields or revisions this verifier does not know (kept,
163    /// never deciding).
164    pub newer_fields: Vec<String>,
165    /// `attest verify`'s object for the commit, unchanged.
166    pub verification: Option<ObjectVerification>,
167}
168
169impl TickReport {
170    /// Whether the tick fails the branch.
171    #[must_use]
172    pub fn fails(&self, require_verified: bool) -> bool {
173        match self.outcome {
174            TickOutcome::Evidenced => {
175                require_verified && self.verdict.as_deref() != Some("verified")
176            }
177            TickOutcome::Stale | TickOutcome::Unverifiable | TickOutcome::Regressed => true,
178        }
179    }
180}
181
182/// A task skipped by a person's decision: no tick, not counted.
183#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
184pub struct SkippedTask {
185    /// The unit.
186    pub feature: String,
187    /// Task ID.
188    pub task: String,
189}
190
191/// Which units a check covers.
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
193#[serde(rename_all = "snake_case")]
194pub enum Mode {
195    /// Every unit whose task list or intent differs between base and head.
196    Touched,
197    /// Every unit (the badge, the default branch).
198    All,
199}
200
201/// The branch's conclusion.
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
203#[serde(rename_all = "snake_case")]
204pub enum Conclusion {
205    /// Every tick carries verifying evidence.
206    Success,
207    /// A tick is unverifiable, stale or regressed (or not verified while
208    /// required), or a claim in range cannot be checked.
209    Failure,
210    /// Nothing to check: never success.
211    Neutral,
212}
213
214impl Conclusion {
215    /// The word reports and check runs use.
216    #[must_use]
217    pub const fn word(self) -> &'static str {
218        match self {
219            Self::Success => "success",
220            Self::Failure => "failure",
221            Self::Neutral => "neutral",
222        }
223    }
224
225    /// The rule (R7): any failing tick, or a claim this version cannot
226    /// check, is failure; nothing to check is neutral; else success.
227    #[must_use]
228    pub fn of(ticks: &[TickReport], require_verified: bool, not_evaluated: &[String]) -> Self {
229        if ticks.iter().any(|t| t.fails(require_verified))
230            || not_evaluated.iter().any(|n| n == TRAILER_ONLY_TASKS)
231        {
232            Self::Failure
233        } else if ticks.is_empty() {
234            Self::Neutral
235        } else {
236            Self::Success
237        }
238    }
239}
240
241/// Counts by outcome, in the verdict bar's order.
242#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, schemars::JsonSchema)]
243pub struct BranchTotals {
244    /// Evidenced `verified`.
245    pub verified: usize,
246    /// Evidenced `regression_only`.
247    pub regression_only: usize,
248    /// Evidenced `attested`.
249    pub attested: usize,
250    /// Stale.
251    pub stale: usize,
252    /// Unverifiable.
253    pub unverifiable: usize,
254    /// Regressed (reserved).
255    pub regressed: usize,
256    /// Skipped by a person (not ticks).
257    pub skipped: usize,
258}
259
260impl BranchTotals {
261    /// The counts of `ticks` (and `skipped` skips).
262    #[must_use]
263    pub fn of(ticks: &[TickReport], skipped: usize) -> Self {
264        let mut t = Self {
265            skipped,
266            ..Self::default()
267        };
268        for r in ticks {
269            match (r.outcome, r.verdict.as_deref()) {
270                (TickOutcome::Evidenced, Some("verified")) => t.verified += 1,
271                (TickOutcome::Evidenced, Some("attested")) => t.attested += 1,
272                (TickOutcome::Evidenced, _) => t.regression_only += 1,
273                (TickOutcome::Stale, _) => t.stale += 1,
274                (TickOutcome::Unverifiable, _) => t.unverifiable += 1,
275                (TickOutcome::Regressed, _) => t.regressed += 1,
276            }
277        }
278        t
279    }
280}
281
282/// Result of `speccoder attest verify --branch` / `speccoder attest badge`.
283#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
284pub struct BranchReport {
285    /// The commit checked.
286    pub head: String,
287    /// The merge base with the given base, when one was given.
288    pub base: Option<String>,
289    /// Which units were covered.
290    pub mode: Mode,
291    /// The units checked, in order.
292    pub features: Vec<String>,
293    /// Every tick (and trailer-only task), in document order per unit.
294    pub ticks: Vec<TickReport>,
295    /// Tasks skipped by a person's decision (informational).
296    pub skipped: Vec<SkippedTask>,
297    /// Counts by outcome.
298    pub totals: BranchTotals,
299    /// The conclusion.
300    pub conclusion: Conclusion,
301    /// The trust used: the allowed-signers file and the revision it was
302    /// read at, the Sigstore identity and issuer.
303    pub trust: serde_json::Value,
304    /// Unsigned evidence was accepted (`--require-signed=false`).
305    pub accepted_unsigned: bool,
306    /// `regression_only` and `attested` fail too.
307    pub require_verified: bool,
308    /// Requirement → task → test → verdict, from the signed `trace[]`,
309    /// with a row (no task) for each requirement no tick cites.
310    pub trace: Vec<TraceRow>,
311    /// Evidenced (not stale) over all ticks; `None` without ticks.
312    pub share: Option<f64>,
313    /// Bug homes touched without a verdict commit (informational).
314    pub open_no_claim: Vec<String>,
315    /// Rules this verifier version does not evaluate yet.
316    pub not_evaluated: Vec<String>,
317    /// Predicate fields or revisions seen but unknown here (tolerated).
318    pub newer_fields: Vec<String>,
319}
320
321impl BranchReport {
322    /// The check run's title: `failure: 7 verified, 1 regression only, …`;
323    /// a neutral outcome says there was nothing to check.
324    #[must_use]
325    pub fn title(&self) -> String {
326        if self.conclusion == Conclusion::Neutral {
327            return "neutral: no ticks checked".to_owned();
328        }
329        let t = &self.totals;
330        let mut title = format!(
331            "{}: {} verified, {} regression only, {} attested, {} stale, {} unverifiable",
332            self.conclusion.word(),
333            t.verified,
334            t.regression_only,
335            t.attested,
336            t.stale,
337            t.unverifiable
338        );
339        if t.regressed > 0 {
340            title.push_str(&format!(", {} regressed", t.regressed));
341        }
342        title
343    }
344}
345
346/// Evidenced (not stale) over all ticks; `None` with none.
347#[must_use]
348pub fn share(ticks: &[TickReport]) -> Option<f64> {
349    if ticks.is_empty() {
350        return None;
351    }
352    let evidenced = ticks
353        .iter()
354        .filter(|t| t.outcome == TickOutcome::Evidenced)
355        .count();
356    #[allow(clippy::cast_precision_loss)] // counts of ticks
357    Some(evidenced as f64 / ticks.len() as f64)
358}
359
360/// The badge: a shields.io endpoint document, and the same as an SVG.
361#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
362pub struct Badge {
363    /// The endpoint schema's version (1).
364    #[serde(rename = "schemaVersion")]
365    pub schema_version: u32,
366    /// `evidence`.
367    pub label: String,
368    /// `80%`, or `none`.
369    pub message: String,
370    /// `brightgreen`, `yellow`, `red`, `lightgrey`.
371    pub color: String,
372}
373
374impl Badge {
375    /// The badge of a report: ≥ 95 % green, ≥ 75 % yellow, else red; never
376    /// green with an unverifiable tick; `none` without ticks.
377    #[must_use]
378    pub fn of(report: &BranchReport) -> Self {
379        let (message, color) = match report.share {
380            None => ("none".to_owned(), "lightgrey"),
381            Some(s) => {
382                let percent = (s * 100.0).floor();
383                let color = if percent >= 95.0 && report.totals.unverifiable == 0 {
384                    "brightgreen"
385                } else if percent >= 75.0 {
386                    "yellow"
387                } else {
388                    "red"
389                };
390                (format!("{percent:.0}%"), color)
391            }
392        };
393        Self {
394            schema_version: 1,
395            label: "evidence".to_owned(),
396            message,
397            color: color.to_owned(),
398        }
399    }
400
401    /// The badge as a flat SVG, its text the same as the endpoint's.
402    #[must_use]
403    pub fn svg(&self) -> String {
404        let fill = match self.color.as_str() {
405            "brightgreen" => "#4c1",
406            "yellow" => "#dfb317",
407            "red" => "#e05d44",
408            _ => "#9f9f9f",
409        };
410        // Roughly 7 px per character of 11 px Verdana, plus padding.
411        let width = |s: &str| u32::try_from(s.chars().count()).unwrap_or(8) * 7 + 10;
412        let (lw, mw) = (width(&self.label), width(&self.message));
413        let total = lw + mw;
414        format!(
415            "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{total}\" height=\"20\" role=\"img\" aria-label=\"{label}: {message}\">\
416<title>{label}: {message}</title>\
417<rect width=\"{lw}\" height=\"20\" fill=\"#555\"/>\
418<rect x=\"{lw}\" width=\"{mw}\" height=\"20\" fill=\"{fill}\"/>\
419<g fill=\"#fff\" text-anchor=\"middle\" font-family=\"Verdana,Geneva,DejaVu Sans,sans-serif\" font-size=\"11\">\
420<text x=\"{lx}\" y=\"14\">{label}</text><text x=\"{mx}\" y=\"14\">{message}</text></g></svg>\n",
421            label = self.label,
422            message = self.message,
423            lx = lw / 2,
424            mx = lw + mw / 2,
425        )
426    }
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432
433    fn tick(task: &str, outcome: TickOutcome, verdict: Option<&str>) -> TickReport {
434        TickReport {
435            tick: Tick {
436                feature: "001-x".to_owned(),
437                task: task.to_owned(),
438                title: String::new(),
439                kind: TickKind::Tick,
440                anchor: Anchor {
441                    path: "specs/001-x/tasks.md".to_owned(),
442                    line: 1,
443                },
444                line_sha256: None,
445                claim_sha256: None,
446            },
447            commit: None,
448            outcome,
449            verdict: verdict.map(str::to_owned),
450            reason: (outcome == TickOutcome::Unverifiable)
451                .then_some(Unverifiable::TickedWithoutEvidence),
452            detail: None,
453            signer: None,
454            decider: None,
455            stale: Vec::new(),
456            stale_anchor: None,
457            regression: None,
458            newer_fields: Vec::new(),
459            verification: None,
460        }
461    }
462
463    fn report(ticks: Vec<TickReport>) -> BranchReport {
464        let conclusion = Conclusion::of(&ticks, false, &[]);
465        BranchReport {
466            head: "h".to_owned(),
467            base: None,
468            mode: Mode::All,
469            features: vec!["001-x".to_owned()],
470            totals: BranchTotals::of(&ticks, 0),
471            share: share(&ticks),
472            ticks,
473            skipped: Vec::new(),
474            conclusion,
475            trust: serde_json::Value::Null,
476            accepted_unsigned: false,
477            require_verified: false,
478            trace: Vec::new(),
479            open_no_claim: Vec::new(),
480            not_evaluated: Vec::new(),
481            newer_fields: Vec::new(),
482        }
483    }
484
485    #[test]
486    fn the_conclusion_is_failure_on_any_failing_tick_and_never_success_without_ticks() {
487        use TickOutcome::*;
488        let ok = tick("T1", Evidenced, Some("verified"));
489        let ro = tick("T2", Evidenced, Some("regression_only"));
490        assert_eq!(Conclusion::of(&[], false, &[]), Conclusion::Neutral);
491        assert_eq!(
492            Conclusion::of(&[ok.clone(), ro.clone()], false, &[]),
493            Conclusion::Success
494        );
495        assert_eq!(
496            Conclusion::of(&[ok.clone(), ro], true, &[]),
497            Conclusion::Failure
498        );
499        for bad in [Stale, Unverifiable, Regressed] {
500            assert_eq!(
501                Conclusion::of(&[ok.clone(), tick("T3", bad, None)], false, &[]),
502                Conclusion::Failure,
503                "{bad:?}"
504            );
505        }
506        // A claim this version cannot check is not proof.
507        assert_eq!(
508            Conclusion::of(&[], false, &[TRAILER_ONLY_TASKS.to_owned()]),
509            Conclusion::Failure
510        );
511        assert_eq!(
512            Conclusion::of(&[ok], false, &[EARLIER_TASK_REGRESSIONS.to_owned()]),
513            Conclusion::Success
514        );
515    }
516
517    #[test]
518    fn the_title_counts_in_the_bars_order() {
519        use TickOutcome::*;
520        let r = report(vec![
521            tick("T1", Evidenced, Some("verified")),
522            tick("T2", Evidenced, Some("attested")),
523            tick("T3", Unverifiable, None),
524        ]);
525        assert_eq!(
526            r.title(),
527            "failure: 1 verified, 0 regression only, 1 attested, 0 stale, 1 unverifiable"
528        );
529        assert_eq!(report(Vec::new()).title(), "neutral: no ticks checked");
530    }
531
532    #[test]
533    fn the_badge_reads_eight_of_ten_as_eighty_percent_and_never_green_with_an_unverifiable_tick() {
534        use TickOutcome::*;
535        let mut ticks: Vec<TickReport> = (0..8)
536            .map(|i| tick(&format!("T{i}"), Evidenced, Some("verified")))
537            .collect();
538        ticks.push(tick("T8", Stale, Some("verified")));
539        ticks.push(tick("T9", Unverifiable, None));
540        let b = Badge::of(&report(ticks));
541        assert_eq!((b.message.as_str(), b.color.as_str()), ("80%", "yellow"));
542        assert!(b.svg().contains(">80%<"));
543        let mut all: Vec<TickReport> = (0..40)
544            .map(|i| tick(&format!("T{i}"), Evidenced, Some("verified")))
545            .collect();
546        all.push(tick("T99", Unverifiable, None));
547        let b = Badge::of(&report(all));
548        assert_eq!((b.message.as_str(), b.color.as_str()), ("97%", "yellow"));
549        assert_eq!(Badge::of(&report(Vec::new())).message, "none");
550    }
551}