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