Skip to main content

structured_pkix/crl/
evidence.rs

1//! Per-source evidence and qualified absence coverage; no cache or fetcher.
2
3use super::{
4    Authenticated, BoundDelta, CombinedEntry, EntryLookup, Freshness, Reasons, ScopeFailure,
5    ScopeOutcome,
6};
7use crate::temporal::{LeapTable, Reading};
8use crate::{Certificate, Error, Result, oid};
9use structured_asn1::memory::{Memory, MemoryError, OwnedVec};
10
11/// Caller-provided acquisition fact for RFC5280 §6.3.3(a)'s endpoint rules.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum SourceOrigin {
14    /// Already cached source: only time strictly after nextUpdate needs refresh.
15    Cached,
16    /// Newly obtained replacement: nextUpdate must be strictly after now.
17    Replacement,
18}
19
20/// Temporal/resource inputs, without a clock, fetching or age-policy defaults.
21pub struct Evaluation<'p> {
22    /// Current caller-provided label, retaining subnanosecond precision.
23    pub now: Reading,
24    /// Complete authenticated UTC facts and explicit coverage supplied by caller.
25    pub utc: &'p LeapTable<'p>,
26    /// Current shared operation domain.
27    pub execution: &'p Memory,
28    /// Acquisition fact for the complete or updating delta source.
29    pub origin: SourceOrigin,
30}
31
32/// Original sources behind established absence coverage.
33#[derive(Debug, Clone, Copy)]
34pub enum EvidenceSource<'r, 'a> {
35    /// Authenticated complete source.
36    Complete(Authenticated<'r, 'a>),
37    /// Authenticated and metadata-bound original pair; no reconstructed CRL.
38    Delta {
39        /// Original complete CRL.
40        base: Authenticated<'r, 'a>,
41        /// Original updating delta CRL.
42        delta: Authenticated<'r, 'a>,
43    },
44}
45
46/// Absence established for these reasons only. It is not a certificate trust
47/// verdict. Sources were scoped, time-qualified and fully checked for critical
48/// demands and entry conflicts before this private constructor could run.
49#[derive(Debug, Clone, Copy)]
50pub struct AbsenceEvidence<'r, 'a> {
51    target: &'r Certificate<'a>,
52    now: Reading,
53    reasons: Reasons,
54    source: EvidenceSource<'r, 'a>,
55}
56impl<'r, 'a> AbsenceEvidence<'r, 'a> {
57    /// Original sources, retaining exact signed-range provenance.
58    #[must_use]
59    pub fn source(self) -> EvidenceSource<'r, 'a> {
60        self.source
61    }
62    /// Qualified reasons, potentially less than all reasons.
63    #[must_use]
64    pub fn reasons(self) -> Reasons {
65        self.reasons
66    }
67}
68
69fn current(freshness: Freshness, origin: SourceOrigin) -> bool {
70    freshness == Freshness::Current
71        || (freshness == Freshness::AtNextUpdate && origin == SourceOrigin::Cached)
72}
73
74/// Completed facts for one applicable authenticated complete CRL.
75#[derive(Debug, Clone, Copy)]
76pub struct CompleteFacts<'b, 'r, 'a> {
77    target: &'r Certificate<'a>,
78    now: Reading,
79    source: &'b Authenticated<'r, 'a>,
80    reasons: Reasons,
81    freshness: Freshness,
82    origin: SourceOrigin,
83    entry: EntryLookup<'r, 'a>,
84}
85impl<'b, 'r, 'a> CompleteFacts<'b, 'r, 'a> {
86    /// Original authenticated source.
87    #[must_use]
88    pub fn source(self) -> &'b Authenticated<'r, 'a> {
89        self.source
90    }
91    /// Applicable reason scope, not yet a non-revocation claim.
92    #[must_use]
93    pub fn reasons(self) -> Reasons {
94        self.reasons
95    }
96    /// UTC-qualified update facts, including unknown/missing expiry.
97    #[must_use]
98    pub fn freshness(self) -> Freshness {
99        self.freshness
100    }
101    /// Full-list entry outcome, including unsupported/conflicting demands.
102    #[must_use]
103    pub fn entry(self) -> EntryLookup<'r, 'a> {
104        self.entry
105    }
106    /// Mint qualified absence only for a usable update window and actual
107    /// absence. Missing nextUpdate never creates an implicit expiry policy.
108    #[must_use]
109    pub fn absence(self) -> Option<AbsenceEvidence<'r, 'a>> {
110        if !current(self.freshness, self.origin) || !matches!(self.entry, EntryLookup::Absent) {
111            return None;
112        }
113        Some(AbsenceEvidence {
114            target: self.target,
115            now: self.now,
116            reasons: self.reasons,
117            source: EvidenceSource::Complete(*self.source),
118        })
119    }
120}
121
122/// Complete-source processing, separate from signature authentication/errors.
123#[derive(Debug, Clone, Copy)]
124pub enum CompleteEvidence<'b, 'r, 'a> {
125    /// Caller supplied an unpaired delta; it cannot establish standalone absence.
126    DeltaNeedsBase,
127    /// Candidate's issuer/DP/category/reasons do not cover this target.
128    NotApplicable(ScopeFailure),
129    /// All component facts retained, including unsuitable time/entry outcomes.
130    Facts(CompleteFacts<'b, 'r, 'a>),
131}
132
133impl<'r, 'a> Authenticated<'r, 'a> {
134    /// Compose applicability, time and full-list entry semantics over this
135    /// original complete source. Caller supplies authenticated issuer path and
136    /// same-anchor provenance as required by RFC5280 §6.3.3(f).
137    pub fn evaluate<'b>(
138        &'b self,
139        target: &'r Certificate<'a>,
140        time: &Evaluation<'_>,
141    ) -> Result<CompleteEvidence<'b, 'r, 'a>> {
142        if self
143            .crl()
144            .tbs()
145            .extensions()
146            .get(oid::DELTA_CRL_INDICATOR, time.execution)?
147            .is_some()
148        {
149            return Ok(CompleteEvidence::DeltaNeedsBase);
150        }
151        let reasons = match self.scope(target, time.execution)? {
152            ScopeOutcome::Applicable(reasons) => reasons,
153            ScopeOutcome::NotApplicable(reason) => {
154                return Ok(CompleteEvidence::NotApplicable(reason));
155            }
156        };
157        let entry = self.entry(target, time.execution)?;
158        let freshness = self.freshness(time.now, time.utc, time.execution)?;
159        Ok(CompleteEvidence::Facts(CompleteFacts {
160            target,
161            now: time.now,
162            source: self,
163            reasons,
164            entry,
165            freshness,
166            origin: time.origin,
167        }))
168    }
169}
170
171/// Component facts for an applicable bound complete/delta pair.
172#[derive(Debug)]
173pub struct DeltaFacts<'b, 'r, 'a> {
174    target: &'r Certificate<'a>,
175    now: Reading,
176    pair: BoundDelta<'b, 'r, 'a>,
177    reasons: Reasons,
178    base_freshness: Freshness,
179    delta_freshness: Freshness,
180    origin: SourceOrigin,
181    entry: CombinedEntry<'r, 'a>,
182}
183impl<'b, 'r, 'a> DeltaFacts<'b, 'r, 'a> {
184    /// Original metadata-bound pair.
185    #[must_use]
186    pub fn pair(&self) -> BoundDelta<'b, 'r, 'a> {
187        self.pair
188    }
189    /// Base time facts; an expired base can still be updated by a current delta.
190    #[must_use]
191    pub fn base_freshness(&self) -> Freshness {
192        self.base_freshness
193    }
194    /// Delta update window governs the combined list (RFC5280 §5.2.4).
195    #[must_use]
196    pub fn delta_freshness(&self) -> Freshness {
197        self.delta_freshness
198    }
199    /// Original delta-first entry outcome and provenance.
200    #[must_use]
201    pub fn entry(&self) -> &CombinedEntry<'r, 'a> {
202        &self.entry
203    }
204    /// Qualified absence/removal is partial until all reasons are covered.
205    #[must_use]
206    pub fn absence(&self) -> Option<AbsenceEvidence<'r, 'a>> {
207        if !current(self.delta_freshness, self.origin)
208            || matches!(
209                self.base_freshness,
210                Freshness::NotYetCurrent | Freshness::InvalidUpdateOrder | Freshness::Uncertain(_)
211            )
212            || !matches!(
213                self.entry,
214                CombinedEntry::Absent | CombinedEntry::Removed { .. }
215            )
216        {
217            return None;
218        }
219        Some(AbsenceEvidence {
220            target: self.target,
221            now: self.now,
222            reasons: self.reasons,
223            source: EvidenceSource::Delta {
224                base: *self.pair.base(),
225                delta: *self.pair.delta(),
226            },
227        })
228    }
229}
230
231/// Bound-pair processing outcome; no standalone delta absence is inferred.
232#[derive(Debug)]
233pub enum DeltaEvidence<'b, 'r, 'a> {
234    /// Pair scope does not cover the target.
235    NotApplicable(ScopeFailure),
236    /// Retained component facts and original delta-first result.
237    Facts(DeltaFacts<'b, 'r, 'a>),
238}
239impl<'b, 'r, 'a> BoundDelta<'b, 'r, 'a> {
240    /// Compose bound-pair scope, both source metadata/entries and temporal facts.
241    /// No base expiry is silently used as the delta's expiry or vice versa.
242    pub fn evaluate(
243        self,
244        target: &'r Certificate<'a>,
245        time: &Evaluation<'_>,
246    ) -> Result<DeltaEvidence<'b, 'r, 'a>> {
247        let reasons = match self.base().scope(target, time.execution)? {
248            ScopeOutcome::Applicable(reasons) => reasons,
249            ScopeOutcome::NotApplicable(reason) => return Ok(DeltaEvidence::NotApplicable(reason)),
250        };
251        let entry = self.entry(target, time.execution)?;
252        let base_freshness = self.base().freshness(time.now, time.utc, time.execution)?;
253        let delta_freshness = self.delta().freshness(time.now, time.utc, time.execution)?;
254        Ok(DeltaEvidence::Facts(DeltaFacts {
255            target,
256            now: time.now,
257            pair: self,
258            reasons,
259            entry,
260            base_freshness,
261            delta_freshness,
262            origin: time.origin,
263        }))
264    }
265}
266
267/// Coverage admission never silently mixes targets or evaluation moments.
268#[derive(Debug, Clone, Copy, PartialEq, Eq)]
269pub enum CoverageAdmission {
270    /// New reason coverage and source provenance retained.
271    Added,
272    /// Source supplies no new reasons; no redundant storage is allocated.
273    Redundant,
274    /// Different original target certificate.
275    DifferentTarget,
276    /// Different evaluation label or subnanosecond remainder.
277    DifferentTime,
278    /// This request-scoped growing collection belongs to another operation
279    /// domain; its allocator must not charge growth work to an earlier request.
280    DifferentExecution,
281}
282
283/// Qualified absence coverage with accounted original-source contributors.
284/// Complete reason coverage alone is not a whole certificate trust verdict;
285/// caller still owns current-source selection and authenticated path provenance.
286#[derive(Debug)]
287pub struct Coverage<'r, 'a> {
288    reasons: Reasons,
289    sources: OwnedVec<AbsenceEvidence<'r, 'a>>,
290}
291impl<'r, 'a> Coverage<'r, 'a> {
292    /// Retain the first qualified source; capacity remains charged until drop.
293    pub fn new(first: AbsenceEvidence<'r, 'a>, execution: &Memory) -> Result<Self> {
294        let mut sources = execution.vector();
295        sources
296            .try_push(first)
297            .map_err(|reason| first.target.tbs().subject().resource(reason))?;
298        Ok(Self {
299            reasons: first.reasons,
300            sources,
301        })
302    }
303    /// Current qualified reason coverage.
304    #[must_use]
305    pub fn reasons(&self) -> Reasons {
306        self.reasons
307    }
308    /// Original evidence behind every contributing reason union.
309    #[must_use]
310    pub fn sources(&self) -> &[AbsenceEvidence<'r, 'a>] {
311        &self.sources
312    }
313    /// Add compatible qualified coverage under the current operation account.
314    /// Growing coverage is request-scoped: `execution` must share its original
315    /// account. Later requests may inspect retained facts, but cannot grow this
316    /// collection using a different work domain. No old-account fallback.
317    pub fn add(
318        &mut self,
319        evidence: AbsenceEvidence<'r, 'a>,
320        execution: &Memory,
321    ) -> Result<CoverageAdmission> {
322        if !self.sources.memory().same_account(execution) {
323            return Ok(CoverageAdmission::DifferentExecution);
324        }
325        let first = &self.sources[0];
326        execution
327            .charge_work(1)
328            .map_err(|reason| evidence.target.tbs().subject().resource(reason))?;
329        if first.now != evidence.now {
330            return Ok(CoverageAdmission::DifferentTime);
331        }
332        let left = first.target.as_bytes();
333        let right = evidence.target.as_bytes();
334        let work = u64::try_from(left.len().max(right.len()))
335            .ok()
336            .and_then(|n| n.checked_mul(2))
337            .and_then(|n| n.checked_add(1))
338            .ok_or_else(|| {
339                Error::resource(
340                    MemoryError::WorkExceeded,
341                    "certificateList.coverage",
342                    evidence.target.span().start,
343                )
344            })?;
345        execution
346            .charge_work(work)
347            .map_err(|reason| evidence.target.tbs().subject().resource(reason))?;
348        if left != right {
349            return Ok(CoverageAdmission::DifferentTarget);
350        }
351        let union = self.reasons.union(evidence.reasons);
352        if union == self.reasons {
353            return Ok(CoverageAdmission::Redundant);
354        }
355        self.sources
356            .try_push(evidence)
357            .map_err(|reason| evidence.target.tbs().subject().resource(reason))?;
358        self.reasons = union;
359        Ok(CoverageAdmission::Added)
360    }
361}