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, RevocationTarget,
5    ScopeFailure, ScopeOutcome,
6};
7use crate::temporal::{LeapTable, Reading};
8use crate::{Certificate, Result, oid};
9use structured_asn1::memory::{Memory, OwnedVec, Work};
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, E: Work = Memory> {
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 E,
28    /// Acquisition fact for the complete or updating delta source.
29    pub origin: SourceOrigin,
30}
31
32/// Original authenticated complete/delta sources. This source descriptor alone
33/// does not assert applicability, freshness or established absence coverage.
34#[derive(Debug, Clone, Copy)]
35pub enum EvidenceSource<'r, 'a> {
36    /// Authenticated complete source.
37    Complete(Authenticated<'r, 'a>),
38    /// Authenticated and metadata-bound original pair; no reconstructed CRL.
39    Delta {
40        /// Original complete CRL.
41        base: Authenticated<'r, 'a>,
42        /// Original updating delta CRL.
43        delta: Authenticated<'r, 'a>,
44    },
45}
46
47/// Absence established for these reasons only. It is not a certificate trust
48/// verdict. Sources were scoped, time-qualified and fully checked for critical
49/// demands and entry conflicts before this private constructor could run.
50#[derive(Debug)]
51pub struct AbsenceEvidence<'r, 'a, T = Certificate<'a>> {
52    target: &'r T,
53    now: Reading,
54    reasons: Reasons,
55    source: EvidenceSource<'r, 'a>,
56}
57impl<T> Copy for AbsenceEvidence<'_, '_, T> {}
58impl<T> Clone for AbsenceEvidence<'_, '_, T> {
59    fn clone(&self) -> Self {
60        *self
61    }
62}
63impl<'r, 'a, T> AbsenceEvidence<'r, 'a, T> {
64    /// Actual original certificate whose status was processed. This association
65    /// comes from the same evaluation, not a later caller-supplied target.
66    #[must_use]
67    pub fn target(self) -> &'r T {
68        self.target
69    }
70    /// Exact evaluation label, including its subnanosecond remainder.
71    #[must_use]
72    pub fn evaluated_at(self) -> Reading {
73        self.now
74    }
75    /// Original sources, retaining exact signed-range provenance.
76    #[must_use]
77    pub fn source(self) -> EvidenceSource<'r, 'a> {
78        self.source
79    }
80    /// Qualified reasons, potentially less than all reasons.
81    #[must_use]
82    pub fn reasons(self) -> Reasons {
83        self.reasons
84    }
85}
86
87fn current(freshness: Freshness, origin: SourceOrigin) -> bool {
88    freshness == Freshness::Current
89        || (freshness == Freshness::AtNextUpdate && origin == SourceOrigin::Cached)
90}
91
92/// Completed facts for one applicable authenticated complete CRL.
93#[derive(Debug)]
94pub struct CompleteFacts<'b, 'r, 'a, T = Certificate<'a>> {
95    target: &'r T,
96    now: Reading,
97    source: &'b Authenticated<'r, 'a>,
98    reasons: Reasons,
99    freshness: Freshness,
100    origin: SourceOrigin,
101    entry: EntryLookup<'r, 'a>,
102}
103impl<T> Copy for CompleteFacts<'_, '_, '_, T> {}
104impl<T> Clone for CompleteFacts<'_, '_, '_, T> {
105    fn clone(&self) -> Self {
106        *self
107    }
108}
109impl<'b, 'r, 'a, T> CompleteFacts<'b, 'r, 'a, T> {
110    /// Original certificate whose applicability and entries were evaluated,
111    /// including negative, stale or conflicting outcomes without absence.
112    #[must_use]
113    pub fn target(self) -> &'r T {
114        self.target
115    }
116    /// Exact original evaluation label, independent of its temporary context.
117    #[must_use]
118    pub fn evaluated_at(self) -> Reading {
119        self.now
120    }
121    /// Original authenticated source.
122    #[must_use]
123    pub fn source(self) -> &'b Authenticated<'r, 'a> {
124        self.source
125    }
126    /// Applicable reason scope, not yet a non-revocation claim.
127    #[must_use]
128    pub fn reasons(self) -> Reasons {
129        self.reasons
130    }
131    /// UTC-qualified update facts, including unknown/missing expiry.
132    #[must_use]
133    pub fn freshness(self) -> Freshness {
134        self.freshness
135    }
136    /// Full-list entry outcome, including unsupported/conflicting demands.
137    #[must_use]
138    pub fn entry(self) -> EntryLookup<'r, 'a> {
139        self.entry
140    }
141    /// Mint qualified absence only for a usable update window and actual
142    /// absence. Missing nextUpdate never creates an implicit expiry policy.
143    #[must_use]
144    pub fn absence(self) -> Option<AbsenceEvidence<'r, 'a, T>> {
145        if !current(self.freshness, self.origin) || !matches!(self.entry, EntryLookup::Absent) {
146            return None;
147        }
148        Some(AbsenceEvidence {
149            target: self.target,
150            now: self.now,
151            reasons: self.reasons,
152            source: EvidenceSource::Complete(*self.source),
153        })
154    }
155}
156
157/// Complete-source processing, separate from signature authentication/errors.
158#[derive(Debug)]
159pub enum CompleteEvidence<'b, 'r, 'a, T = Certificate<'a>> {
160    /// Caller supplied an unpaired delta; it cannot establish standalone absence.
161    DeltaNeedsBase,
162    /// Candidate's issuer/DP/category/reasons do not cover this target.
163    NotApplicable(ScopeFailure),
164    /// All component facts retained, including unsuitable time/entry outcomes.
165    Facts(CompleteFacts<'b, 'r, 'a, T>),
166}
167impl<T> Copy for CompleteEvidence<'_, '_, '_, T> {}
168impl<T> Clone for CompleteEvidence<'_, '_, '_, T> {
169    fn clone(&self) -> Self {
170        *self
171    }
172}
173
174impl<'r, 'a> Authenticated<'r, 'a> {
175    /// Compose applicability, time and full-list entry semantics over this
176    /// original complete source. Caller supplies authenticated issuer path and
177    /// same-anchor provenance as required by RFC5280 §6.3.3(f).
178    pub fn evaluate<'b, T: RevocationTarget<'a>, E: Work>(
179        &'b self,
180        target: &'r T,
181        time: &Evaluation<'_, E>,
182    ) -> Result<CompleteEvidence<'b, 'r, 'a, T>> {
183        if self
184            .crl()
185            .tbs()
186            .extensions()
187            .get(oid::DELTA_CRL_INDICATOR, time.execution)?
188            .is_some()
189        {
190            return Ok(CompleteEvidence::DeltaNeedsBase);
191        }
192        let reasons = match self.scope(target, time.execution)? {
193            ScopeOutcome::Applicable(reasons) => reasons,
194            ScopeOutcome::NotApplicable(reason) => {
195                return Ok(CompleteEvidence::NotApplicable(reason));
196            }
197        };
198        let entry = self.entry(target, time.execution)?;
199        let freshness = self.freshness(time.now, time.utc, time.execution)?;
200        Ok(CompleteEvidence::Facts(CompleteFacts {
201            target,
202            now: time.now,
203            source: self,
204            reasons,
205            entry,
206            freshness,
207            origin: time.origin,
208        }))
209    }
210}
211
212/// Component facts for an applicable bound complete/delta pair.
213#[derive(Debug)]
214pub struct DeltaFacts<'b, 'r, 'a, T = Certificate<'a>> {
215    target: &'r T,
216    now: Reading,
217    pair: BoundDelta<'b, 'r, 'a>,
218    reasons: Reasons,
219    base_freshness: Freshness,
220    delta_freshness: Freshness,
221    origin: SourceOrigin,
222    entry: CombinedEntry<'r, 'a>,
223}
224impl<'b, 'r, 'a, T> DeltaFacts<'b, 'r, 'a, T> {
225    /// Original certificate processed by this metadata-bound source pair,
226    /// independently of whether its entry/window establishes absence.
227    #[must_use]
228    pub fn target(&self) -> &'r T {
229        self.target
230    }
231    /// Exact original evaluation label, without borrowing the evaluation context.
232    #[must_use]
233    pub fn evaluated_at(&self) -> Reading {
234        self.now
235    }
236    /// Original metadata-bound pair.
237    #[must_use]
238    pub fn pair(&self) -> BoundDelta<'b, 'r, 'a> {
239        self.pair
240    }
241    /// Base time facts; an expired base can still be updated by a current delta.
242    #[must_use]
243    pub fn base_freshness(&self) -> Freshness {
244        self.base_freshness
245    }
246    /// Delta update window governs the combined list (RFC5280 §5.2.4).
247    #[must_use]
248    pub fn delta_freshness(&self) -> Freshness {
249        self.delta_freshness
250    }
251    /// Original delta-first entry outcome and provenance.
252    #[must_use]
253    pub fn entry(&self) -> &CombinedEntry<'r, 'a> {
254        &self.entry
255    }
256    /// Qualified absence/removal is partial until all reasons are covered.
257    #[must_use]
258    pub fn absence(&self) -> Option<AbsenceEvidence<'r, 'a, T>> {
259        if !current(self.delta_freshness, self.origin)
260            || matches!(
261                self.base_freshness,
262                Freshness::NotYetCurrent | Freshness::InvalidUpdateOrder | Freshness::Uncertain(_)
263            )
264            || !matches!(
265                self.entry,
266                CombinedEntry::Absent | CombinedEntry::Removed { .. }
267            )
268        {
269            return None;
270        }
271        Some(AbsenceEvidence {
272            target: self.target,
273            now: self.now,
274            reasons: self.reasons,
275            source: EvidenceSource::Delta {
276                base: *self.pair.base(),
277                delta: *self.pair.delta(),
278            },
279        })
280    }
281}
282
283/// Bound-pair processing outcome; no standalone delta absence is inferred.
284#[derive(Debug)]
285pub enum DeltaEvidence<'b, 'r, 'a, T = Certificate<'a>> {
286    /// Pair scope does not cover the target.
287    NotApplicable(ScopeFailure),
288    /// Retained component facts and original delta-first result.
289    Facts(DeltaFacts<'b, 'r, 'a, T>),
290}
291impl<'b, 'r, 'a> BoundDelta<'b, 'r, 'a> {
292    /// Compose bound-pair scope, both source metadata/entries and temporal facts.
293    /// No base expiry is silently used as the delta's expiry or vice versa.
294    pub fn evaluate<T: RevocationTarget<'a>, E: Work>(
295        self,
296        target: &'r T,
297        time: &Evaluation<'_, E>,
298    ) -> Result<DeltaEvidence<'b, 'r, 'a, T>> {
299        let reasons = match self.base().scope(target, time.execution)? {
300            ScopeOutcome::Applicable(reasons) => reasons,
301            ScopeOutcome::NotApplicable(reason) => return Ok(DeltaEvidence::NotApplicable(reason)),
302        };
303        let entry = self.entry(target, time.execution)?;
304        let base_freshness = self.base().freshness(time.now, time.utc, time.execution)?;
305        let delta_freshness = self.delta().freshness(time.now, time.utc, time.execution)?;
306        Ok(DeltaEvidence::Facts(DeltaFacts {
307            target,
308            now: time.now,
309            pair: self,
310            reasons,
311            entry,
312            base_freshness,
313            delta_freshness,
314            origin: time.origin,
315        }))
316    }
317}
318
319/// Coverage admission never silently mixes targets or evaluation moments.
320#[derive(Debug, Clone, Copy, PartialEq, Eq)]
321pub enum CoverageAdmission {
322    /// New reason coverage and source provenance retained.
323    Added,
324    /// Source supplies no new reasons; no redundant storage is allocated.
325    Redundant,
326    /// Different original target certificate.
327    DifferentTarget,
328    /// Different evaluation label or subnanosecond remainder.
329    DifferentTime,
330    /// This request-scoped growing collection belongs to another operation
331    /// domain; its allocator must not charge growth work to an earlier request.
332    DifferentExecution,
333}
334
335/// Qualified absence coverage with accounted original-source contributors.
336/// Complete reason coverage alone is not a whole certificate trust verdict;
337/// caller still owns current-source selection and authenticated path provenance.
338#[derive(Debug)]
339pub struct Coverage<'r, 'a, T = Certificate<'a>> {
340    reasons: Reasons,
341    // Temporarily moved, never copied, while attaching the current growth
342    // execution. Every returning path restores the independently owned buffer.
343    sources: Option<OwnedVec<AbsenceEvidence<'r, 'a, T>>>,
344}
345impl<'r, 'a, T: RevocationTarget<'a>> Coverage<'r, 'a, T> {
346    /// Retain the first qualified source; capacity remains charged until drop.
347    pub fn new<W: Work>(first: AbsenceEvidence<'r, 'a, T>, execution: &W) -> Result<Self> {
348        let mut sources = execution.vector();
349        sources
350            .try_push(first)
351            .map_err(|reason| first.target.issuer().resource(reason))?;
352        Ok(Self {
353            reasons: first.reasons,
354            sources: Some(sources.into_owned()),
355        })
356    }
357    /// Current qualified reason coverage.
358    #[must_use]
359    pub fn reasons(&self) -> Reasons {
360        self.reasons
361    }
362    /// Original target shared by every contributing reason source.
363    ///
364    /// # Panics
365    /// Only if the private nonempty source invariant is violated.
366    #[must_use]
367    pub fn target(&self) -> &'r T {
368        self.sources()[0].target()
369    }
370    /// Exact evaluation label shared by every contributing reason source.
371    ///
372    /// # Panics
373    /// Only if the private nonempty source invariant is violated.
374    #[must_use]
375    pub fn evaluated_at(&self) -> Reading {
376        self.sources()[0].evaluated_at()
377    }
378    /// Original evidence behind every contributing reason union.
379    ///
380    /// # Panics
381    /// Only if the internal growth invariant is violated: each public return
382    /// restores the owned source buffer before exposing this collection.
383    #[must_use]
384    pub fn sources(&self) -> &[AbsenceEvidence<'r, 'a, T>] {
385        self.sources
386            .as_deref()
387            .expect("coverage growth restores its buffer")
388    }
389    /// Add compatible qualified coverage under the current operation account.
390    /// Growing coverage is request-scoped: `execution` must share its original
391    /// account. Later requests may inspect retained facts, but cannot grow this
392    /// collection using a different work domain. No old-account fallback.
393    ///
394    /// # Panics
395    /// Only if the internal owned-buffer or checked-account transfer invariant
396    /// is violated. Resource refusals restore the original collection.
397    pub fn add<W: Work>(
398        &mut self,
399        evidence: AbsenceEvidence<'r, 'a, T>,
400        execution: &W,
401    ) -> Result<CoverageAdmission> {
402        if !self
403            .sources
404            .as_ref()
405            .expect("coverage owns its buffer")
406            .memory()
407            .same_account(execution.memory())
408        {
409            return Ok(CoverageAdmission::DifferentExecution);
410        }
411        let first = &self.sources()[0];
412        execution
413            .charge_work(1)
414            .map_err(|reason| evidence.target.issuer().resource(reason))?;
415        if first.now != evidence.now {
416            return Ok(CoverageAdmission::DifferentTime);
417        }
418        let left = first.target.encoded();
419        let right = evidence.target.encoded();
420        for target in [first.target, evidence.target] {
421            target
422                .execution_in(execution)
423                .charge_work(1)
424                .map_err(|reason| target.issuer().resource(reason))?;
425        }
426        if first.target.kind() != evidence.target.kind() || left.len() != right.len() {
427            return Ok(CoverageAdmission::DifferentTarget);
428        }
429        if !core::ptr::eq(first.target, evidence.target) {
430            for target in [first.target, evidence.target] {
431                target
432                    .execution_in(execution)
433                    .charge_work(target.encoded().len() as u64)
434                    .map_err(|reason| target.issuer().resource(reason))?;
435            }
436            if left != right {
437                return Ok(CoverageAdmission::DifferentTarget);
438            }
439        }
440        let union = self.reasons.union(evidence.reasons);
441        if union == self.reasons {
442            return Ok(CoverageAdmission::Redundant);
443        }
444        let sources = self.sources.take().expect("coverage owns its buffer");
445        let Ok(mut scoped) = sources.with_execution(execution) else {
446            unreachable!("the account was checked before any coverage work")
447        };
448        let admitted = scoped.try_push(evidence);
449        self.sources = Some(scoped.into_owned());
450        admitted.map_err(|reason| evidence.target.issuer().resource(reason))?;
451        self.reasons = union;
452        Ok(CoverageAdmission::Added)
453    }
454}