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