Skip to main content

confium_signatif/
coverage.rs

1//! Coverage reports and trust classification (SIGNATIF §14).
2//!
3//! The pipeline produces an **objective** [`CoverageReport`] — facts,
4//! not judgements. The scheme's [`ClassificationPolicy`] maps the
5//! report to a [`ClassificationLabel`] (a pure, deterministic function
6//! published in the deployment manifest). The verifier's
7//! [`AcceptancePolicy`] maps the label to an accept/reject decision for
8//! a given decision context. Three layers, three owners.
9
10use serde::{Deserialize, Serialize};
11
12/// The objective verification facts collected by the pipeline.
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14pub struct CoverageReport {
15    /// Whether every hard check passed (format, signatures, chain,
16    /// scope narrowing, conditions, revocation).
17    pub hard_checks: HardCheckStatus,
18    /// Whether the artifact (or its certificates) are provably included
19    /// in a recognized transparency log.
20    pub transparency_included: bool,
21    /// Whether a time dimension attestation anchored to an external
22    /// source was verified.
23    pub time_anchored: bool,
24    /// The trust dimensions whose attestations verified.
25    pub dimensions_verified: Vec<String>,
26    /// Count of independently attested dimensions.
27    pub dimension_count: usize,
28    /// Count of distinct roots across verified paths (cross-domain
29    /// diversity).
30    pub independent_roots: usize,
31    /// Whether the multi-log M-of-K inclusion quorum was met.
32    pub multi_log_quorum: bool,
33    /// Number of valid verification paths found.
34    pub paths_found: usize,
35    /// Downgrade reasons accumulated from soft checks.
36    pub downgrades: Vec<String>,
37}
38
39/// Hard-check outcome.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "snake_case")]
42pub enum HardCheckStatus {
43    /// All hard checks passed.
44    Pass,
45    /// A hard check failed — the pipeline short-circuits to rejected.
46    Fail,
47}
48
49/// A classification label produced by a scheme's policy.
50///
51/// The reference stack uses: `unverified`, `basic`, `verified`,
52/// `attested`, `certified`, `rejected`; schemes may define their own.
53#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
54pub struct ClassificationLabel(pub String);
55
56impl ClassificationLabel {
57    /// The mandatory rejection label.
58    pub const REJECTED: &'static str = "rejected";
59
60    /// Build an arbitrary label.
61    pub fn new(label: impl Into<String>) -> Self {
62        Self(label.into())
63    }
64}
65
66/// A scheme-defined classification policy: a pure function from the
67/// coverage report to a label. The reference policy implements the
68/// Annex E ladder; schemes replace it via their deployment manifest.
69pub trait ClassificationPolicy {
70    /// Map a coverage report to a classification label.
71    fn classify(&self, report: &CoverageReport) -> ClassificationLabel;
72}
73
74/// The reference ladder from Annex E:
75/// rejected → unverified → basic → verified → attested → certified.
76///
77/// - any hard-check failure → `rejected`
78/// - no transparency, no time anchor, no person → `unverified`
79/// - transparency only → `basic`
80/// - transparency + time → `verified`
81/// - + person dimension → `attested`
82/// - + ≥2 independent roots → `certified`
83#[derive(Debug, Clone, Default)]
84pub struct ReferenceClassificationPolicy;
85
86impl ClassificationPolicy for ReferenceClassificationPolicy {
87    fn classify(&self, report: &CoverageReport) -> ClassificationLabel {
88        if report.hard_checks == HardCheckStatus::Fail {
89            return ClassificationLabel::new(ClassificationLabel::REJECTED);
90        }
91        if !report.transparency_included {
92            return ClassificationLabel::new("unverified");
93        }
94        if !report.time_anchored {
95            return ClassificationLabel::new("basic");
96        }
97        // Algorithm agility (§20): a deprecated algorithm caps the
98        // label — the artifact verifies but cannot reach the top
99        // grades until migrated.
100        let has_deprecated = report
101            .downgrades
102            .iter()
103            .any(|d| d.starts_with("deprecated_algorithm:"));
104        let dims: Vec<&str> = report
105            .dimensions_verified
106            .iter()
107            .map(|s| s.as_str())
108            .collect();
109        let has_person = dims.contains(&"person") && !has_deprecated;
110        if !has_person {
111            return ClassificationLabel::new("verified");
112        }
113        if report.independent_roots >= 2 {
114            ClassificationLabel::new("certified")
115        } else {
116            ClassificationLabel::new("attested")
117        }
118    }
119}
120
121/// The verifier's acceptance policy: label → decision, per decision
122/// context. This is the verifier's own risk posture, not the scheme's.
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct AcceptancePolicy {
125    /// Labels accepted (everything else rejected).
126    pub accepted_labels: Vec<String>,
127}
128
129impl AcceptancePolicy {
130    /// A policy accepting exactly `labels`.
131    pub fn accept(labels: &[&str]) -> Self {
132        Self {
133            accepted_labels: labels.iter().map(|s| s.to_string()).collect(),
134        }
135    }
136
137    /// Decide for a label.
138    pub fn decide(&self, label: &ClassificationLabel) -> Acceptance {
139        if self.accepted_labels.iter().any(|l| l == &label.0) {
140            Acceptance::Accept
141        } else {
142            Acceptance::Reject
143        }
144    }
145}
146
147/// The acceptance decision.
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub enum Acceptance {
150    /// The artifact is accepted for this decision context.
151    Accept,
152    /// The artifact is rejected for this decision context.
153    Reject,
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    fn report(t: bool, time: bool, dims: &[&str], roots: usize) -> CoverageReport {
161        CoverageReport {
162            hard_checks: HardCheckStatus::Pass,
163            transparency_included: t,
164            time_anchored: time,
165            dimensions_verified: dims.iter().map(|s| s.to_string()).collect(),
166            dimension_count: dims.len(),
167            independent_roots: roots,
168            multi_log_quorum: false,
169            paths_found: 1,
170            downgrades: vec![],
171        }
172    }
173
174    #[test]
175    fn reference_ladder() {
176        let p = ReferenceClassificationPolicy;
177        assert_eq!(
178            p.classify(&report(false, false, &["data"], 1)).0,
179            "unverified"
180        );
181        assert_eq!(p.classify(&report(true, false, &["data"], 1)).0, "basic");
182        assert_eq!(p.classify(&report(true, true, &["data"], 1)).0, "verified");
183        assert_eq!(
184            p.classify(&report(true, true, &["data", "person"], 1)).0,
185            "attested"
186        );
187        assert_eq!(
188            p.classify(&report(true, true, &["data", "person"], 2)).0,
189            "certified"
190        );
191
192        let mut failed = report(true, true, &["data"], 1);
193        failed.hard_checks = HardCheckStatus::Fail;
194        assert_eq!(p.classify(&failed).0, "rejected");
195    }
196
197    #[test]
198    fn acceptance_policy_decides_by_label() {
199        let policy = AcceptancePolicy::accept(&["verified", "attested", "certified"]);
200        assert_eq!(
201            policy.decide(&ClassificationLabel::new("attested")),
202            Acceptance::Accept
203        );
204        assert_eq!(
205            policy.decide(&ClassificationLabel::new("basic")),
206            Acceptance::Reject
207        );
208        assert_eq!(
209            policy.decide(&ClassificationLabel::new("rejected")),
210            Acceptance::Reject
211        );
212    }
213}