Skip to main content

dnspls_core/
reduction.rs

1use std::{collections::BTreeSet, error::Error, fmt};
2
3use dnspls_domain::DomainName;
4use serde::{Deserialize, Serialize};
5
6use crate::{
7    DnsOutcome, EvidenceFact, Observation, ObservationId, ProviderAuthority, ProviderStatus,
8    RdapOutcome, TemporalState, UnixMillis, ZoneMembership,
9};
10
11/// Deterministic, user-facing acquisition decision.
12#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum VerdictStatus {
15    Registrable,
16    RegistryPremium,
17    AftermarketFixed,
18    AftermarketOffer,
19    Auction,
20    Reserved,
21    Registered,
22    Unsupported,
23    Pending,
24    Unknown,
25    Disputed,
26}
27
28/// Strongest evidence class that supports a verdict.
29#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum EvidenceTier {
32    None,
33    LocalSnapshot,
34    NetworkSignal,
35    PublicRegistrationData,
36    ProviderReported,
37    RegistrationCapable,
38    RegistryEpp,
39}
40
41#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum DecisionReason {
44    FreshRegistryResult,
45    FreshRegistrationCapableResult,
46    ProviderReportOnly,
47    RdapObjectFound,
48    ZonePresenceObserved,
49    DnsDelegationObserved,
50    AbsenceRequiresPreciseVerification,
51    NoDecisiveEvidence,
52    ContradictoryEvidence,
53}
54
55#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum ContradictionKind {
58    RegistrationVsRegistrable,
59    PreciseProviderDisagreement,
60    ReportedProviderDisagreement,
61}
62
63#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
64pub struct Contradiction {
65    kind: ContradictionKind,
66    observation_ids: Vec<ObservationId>,
67}
68
69impl Contradiction {
70    pub const fn kind(&self) -> ContradictionKind {
71        self.kind
72    }
73
74    pub fn observation_ids(&self) -> &[ObservationId] {
75        &self.observation_ids
76    }
77}
78
79#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
80pub struct Verdict {
81    domain: DomainName,
82    status: VerdictStatus,
83    evidence_tier: EvidenceTier,
84    reason: DecisionReason,
85    can_attempt_registration: bool,
86    considered_observation_ids: Vec<ObservationId>,
87    decisive_observation_ids: Vec<ObservationId>,
88    contradictions: Vec<Contradiction>,
89}
90
91impl Verdict {
92    pub const fn domain(&self) -> &DomainName {
93        &self.domain
94    }
95
96    pub const fn status(&self) -> VerdictStatus {
97        self.status
98    }
99
100    pub const fn evidence_tier(&self) -> EvidenceTier {
101        self.evidence_tier
102    }
103
104    pub const fn reason(&self) -> DecisionReason {
105        self.reason
106    }
107
108    pub const fn can_attempt_registration(&self) -> bool {
109        self.can_attempt_registration
110    }
111
112    pub fn considered_observation_ids(&self) -> &[ObservationId] {
113        &self.considered_observation_ids
114    }
115
116    pub fn decisive_observation_ids(&self) -> &[ObservationId] {
117        &self.decisive_observation_ids
118    }
119
120    pub fn contradictions(&self) -> &[Contradiction] {
121        &self.contradictions
122    }
123}
124
125#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
126pub struct ReductionError {
127    observation_id: ObservationId,
128}
129
130impl ReductionError {
131    pub const fn observation_id(&self) -> &ObservationId {
132        &self.observation_id
133    }
134}
135
136impl fmt::Display for ReductionError {
137    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
138        write!(
139            formatter,
140            "observation {} belongs to a different domain",
141            self.observation_id
142        )
143    }
144}
145
146impl Error for ReductionError {}
147
148/// Reduces immutable observations without discarding provenance or disagreement.
149///
150/// # Errors
151///
152/// Returns [`ReductionError`] if any observation belongs to another domain.
153pub fn reduce(
154    domain: &DomainName,
155    observations: &[Observation],
156    now: UnixMillis,
157) -> Result<Verdict, ReductionError> {
158    validate_domains(domain, observations)?;
159
160    let considered = observations
161        .iter()
162        .map(|observation| observation.id().clone())
163        .collect::<Vec<_>>();
164    let evidence = FreshEvidence::collect(observations, now);
165
166    if let Some(verdict) = find_contradiction(
167        domain,
168        &considered,
169        &evidence.precise,
170        &evidence.reported,
171        &evidence.rdap_found,
172    ) {
173        return Ok(verdict);
174    }
175
176    Ok(reduce_consistent(domain, &considered, &evidence))
177}
178
179struct FreshEvidence<'observation> {
180    all: Vec<&'observation Observation>,
181    precise: Vec<(&'observation Observation, ProviderAuthority, ProviderStatus)>,
182    reported: Vec<(&'observation Observation, ProviderStatus)>,
183    rdap_found: Vec<&'observation Observation>,
184}
185
186impl<'observation> FreshEvidence<'observation> {
187    fn collect(observations: &'observation [Observation], now: UnixMillis) -> Self {
188        let all = observations
189            .iter()
190            .filter(|observation| observation.window().state_at(now) == TemporalState::Fresh)
191            .collect::<Vec<_>>();
192        let precise = all
193            .iter()
194            .copied()
195            .filter_map(|observation| match observation.fact() {
196                EvidenceFact::ProviderCheck {
197                    authority, status, ..
198                } if *authority >= ProviderAuthority::RegistrationCapable => {
199                    Some((observation, *authority, *status))
200                }
201                _ => None,
202            })
203            .collect();
204        let reported = all
205            .iter()
206            .copied()
207            .filter_map(|observation| match observation.fact() {
208                EvidenceFact::ProviderCheck {
209                    authority: ProviderAuthority::Reported,
210                    status,
211                    ..
212                } => Some((observation, *status)),
213                _ => None,
214            })
215            .collect();
216        let rdap_found = all
217            .iter()
218            .copied()
219            .filter(|observation| {
220                matches!(
221                    observation.fact(),
222                    EvidenceFact::Rdap {
223                        outcome: RdapOutcome::ObjectFound
224                    }
225                )
226            })
227            .collect();
228        Self {
229            all,
230            precise,
231            reported,
232            rdap_found,
233        }
234    }
235}
236
237fn validate_domains(
238    domain: &DomainName,
239    observations: &[Observation],
240) -> Result<(), ReductionError> {
241    if let Some(observation) = observations
242        .iter()
243        .find(|observation| observation.domain() != domain)
244    {
245        return Err(ReductionError {
246            observation_id: observation.id().clone(),
247        });
248    }
249    Ok(())
250}
251
252fn reduce_consistent(
253    domain: &DomainName,
254    considered: &[ObservationId],
255    evidence: &FreshEvidence<'_>,
256) -> Verdict {
257    precise_verdict(domain, considered, &evidence.precise)
258        .or_else(|| rdap_verdict(domain, considered, &evidence.rdap_found))
259        .or_else(|| reported_verdict(domain, considered, &evidence.reported))
260        .or_else(|| presence_verdict(domain, considered, &evidence.all))
261        .unwrap_or_else(|| unknown_verdict(domain, considered, &evidence.all))
262}
263
264fn precise_verdict(
265    domain: &DomainName,
266    considered: &[ObservationId],
267    evidence: &[(&Observation, ProviderAuthority, ProviderStatus)],
268) -> Option<Verdict> {
269    let (observation, authority, status) = strongest_precise(evidence)?;
270    let reason = if authority == ProviderAuthority::RegistryEpp {
271        DecisionReason::FreshRegistryResult
272    } else {
273        DecisionReason::FreshRegistrationCapableResult
274    };
275    Some(make_verdict(
276        domain,
277        status.into(),
278        authority_to_tier(authority),
279        reason,
280        is_registration_attempt(status),
281        considered.to_vec(),
282        vec![observation.id().clone()],
283        Vec::new(),
284    ))
285}
286
287fn rdap_verdict(
288    domain: &DomainName,
289    considered: &[ObservationId],
290    evidence: &[&Observation],
291) -> Option<Verdict> {
292    (!evidence.is_empty()).then(|| {
293        make_verdict(
294            domain,
295            VerdictStatus::Registered,
296            EvidenceTier::PublicRegistrationData,
297            DecisionReason::RdapObjectFound,
298            false,
299            considered.to_vec(),
300            ids(evidence),
301            Vec::new(),
302        )
303    })
304}
305
306fn reported_verdict(
307    domain: &DomainName,
308    considered: &[ObservationId],
309    evidence: &[(&Observation, ProviderStatus)],
310) -> Option<Verdict> {
311    let (observation, status) = evidence.first().copied()?;
312    let status = if is_registration_attempt(status) {
313        VerdictStatus::Unknown
314    } else {
315        status.into()
316    };
317    Some(make_verdict(
318        domain,
319        status,
320        EvidenceTier::ProviderReported,
321        DecisionReason::ProviderReportOnly,
322        false,
323        considered.to_vec(),
324        vec![observation.id().clone()],
325        Vec::new(),
326    ))
327}
328
329fn presence_verdict(
330    domain: &DomainName,
331    considered: &[ObservationId],
332    evidence: &[&Observation],
333) -> Option<Verdict> {
334    let (observation, tier, reason) =
335        evidence
336            .iter()
337            .copied()
338            .find_map(|observation| match observation.fact() {
339                EvidenceFact::ZoneSnapshot {
340                    membership: ZoneMembership::Present,
341                } => Some((
342                    observation,
343                    EvidenceTier::LocalSnapshot,
344                    DecisionReason::ZonePresenceObserved,
345                )),
346                EvidenceFact::Dns {
347                    outcome: DnsOutcome::Delegated,
348                    ..
349                } => Some((
350                    observation,
351                    EvidenceTier::NetworkSignal,
352                    DecisionReason::DnsDelegationObserved,
353                )),
354                _ => None,
355            })?;
356    Some(make_verdict(
357        domain,
358        VerdictStatus::Registered,
359        tier,
360        reason,
361        false,
362        considered.to_vec(),
363        vec![observation.id().clone()],
364        Vec::new(),
365    ))
366}
367
368fn unknown_verdict(
369    domain: &DomainName,
370    considered: &[ObservationId],
371    evidence: &[&Observation],
372) -> Verdict {
373    let has_absence = evidence.iter().any(|observation| {
374        matches!(
375            observation.fact(),
376            EvidenceFact::ZoneSnapshot {
377                membership: ZoneMembership::Absent
378            } | EvidenceFact::Dns {
379                outcome: DnsOutcome::NameError,
380                ..
381            } | EvidenceFact::Rdap {
382                outcome: RdapOutcome::ObjectNotFound
383            }
384        )
385    });
386    let (tier, reason) = if has_absence {
387        (
388            EvidenceTier::NetworkSignal,
389            DecisionReason::AbsenceRequiresPreciseVerification,
390        )
391    } else {
392        (EvidenceTier::None, DecisionReason::NoDecisiveEvidence)
393    };
394    make_verdict(
395        domain,
396        VerdictStatus::Unknown,
397        tier,
398        reason,
399        false,
400        considered.to_vec(),
401        Vec::new(),
402        Vec::new(),
403    )
404}
405
406fn find_contradiction(
407    domain: &DomainName,
408    considered: &[ObservationId],
409    precise: &[(&Observation, ProviderAuthority, ProviderStatus)],
410    reported: &[(&Observation, ProviderStatus)],
411    rdap_found: &[&Observation],
412) -> Option<Verdict> {
413    let precise_statuses = precise
414        .iter()
415        .map(|(_, _, status)| *status)
416        .collect::<BTreeSet<_>>();
417    if precise_statuses.len() > 1 {
418        let evidence = precise
419            .iter()
420            .map(|(observation, _, _)| *observation)
421            .collect::<Vec<_>>();
422        return Some(disputed(
423            domain,
424            considered,
425            ContradictionKind::PreciseProviderDisagreement,
426            precise
427                .iter()
428                .map(|(_, authority, _)| authority_to_tier(*authority))
429                .max_by_key(|tier| evidence_tier_rank(*tier))
430                .unwrap_or(EvidenceTier::RegistrationCapable),
431            &evidence,
432        ));
433    }
434
435    let reported_statuses = reported
436        .iter()
437        .map(|(_, status)| status_category(*status))
438        .collect::<BTreeSet<_>>();
439    if reported_statuses.len() > 1 {
440        let evidence = reported
441            .iter()
442            .map(|(observation, _)| *observation)
443            .collect::<Vec<_>>();
444        return Some(disputed(
445            domain,
446            considered,
447            ContradictionKind::ReportedProviderDisagreement,
448            EvidenceTier::ProviderReported,
449            &evidence,
450        ));
451    }
452
453    let availability_claims = precise
454        .iter()
455        .filter_map(|(observation, _, status)| {
456            is_registration_attempt(*status).then_some(*observation)
457        })
458        .chain(reported.iter().filter_map(|(observation, status)| {
459            is_registration_attempt(*status).then_some(*observation)
460        }))
461        .collect::<Vec<_>>();
462    if !rdap_found.is_empty() && !availability_claims.is_empty() {
463        let mut evidence = availability_claims;
464        evidence.extend(rdap_found.iter().copied());
465        return Some(disputed(
466            domain,
467            considered,
468            ContradictionKind::RegistrationVsRegistrable,
469            precise
470                .iter()
471                .map(|(_, authority, _)| authority_to_tier(*authority))
472                .max_by_key(|tier| evidence_tier_rank(*tier))
473                .unwrap_or(EvidenceTier::PublicRegistrationData),
474            &evidence,
475        ));
476    }
477
478    None
479}
480
481fn disputed(
482    domain: &DomainName,
483    considered: &[ObservationId],
484    kind: ContradictionKind,
485    evidence_tier: EvidenceTier,
486    evidence: &[&Observation],
487) -> Verdict {
488    let decisive = ids(evidence);
489    make_verdict(
490        domain,
491        VerdictStatus::Disputed,
492        evidence_tier,
493        DecisionReason::ContradictoryEvidence,
494        false,
495        considered.to_vec(),
496        decisive.clone(),
497        vec![Contradiction {
498            kind,
499            observation_ids: decisive,
500        }],
501    )
502}
503
504const fn authority_to_tier(authority: ProviderAuthority) -> EvidenceTier {
505    match authority {
506        ProviderAuthority::Reported => EvidenceTier::ProviderReported,
507        ProviderAuthority::RegistrationCapable => EvidenceTier::RegistrationCapable,
508        ProviderAuthority::RegistryEpp => EvidenceTier::RegistryEpp,
509    }
510}
511
512const fn evidence_tier_rank(tier: EvidenceTier) -> u8 {
513    match tier {
514        EvidenceTier::None => 0,
515        EvidenceTier::LocalSnapshot => 1,
516        EvidenceTier::NetworkSignal => 2,
517        EvidenceTier::ProviderReported => 3,
518        EvidenceTier::PublicRegistrationData => 4,
519        EvidenceTier::RegistrationCapable => 5,
520        EvidenceTier::RegistryEpp => 6,
521    }
522}
523
524fn strongest_precise<'observation>(
525    precise: &[(&'observation Observation, ProviderAuthority, ProviderStatus)],
526) -> Option<(&'observation Observation, ProviderAuthority, ProviderStatus)> {
527    precise
528        .iter()
529        .copied()
530        .max_by_key(|(_, authority, _)| *authority)
531}
532
533fn ids(observations: &[&Observation]) -> Vec<ObservationId> {
534    observations
535        .iter()
536        .map(|observation| observation.id().clone())
537        .collect()
538}
539
540#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
541enum StatusCategory {
542    Acquisition,
543    Unavailable,
544    Indeterminate,
545}
546
547const fn status_category(status: ProviderStatus) -> StatusCategory {
548    match status {
549        ProviderStatus::Registrable
550        | ProviderStatus::RegistryPremium
551        | ProviderStatus::AftermarketFixed
552        | ProviderStatus::AftermarketOffer
553        | ProviderStatus::Auction => StatusCategory::Acquisition,
554        ProviderStatus::Reserved | ProviderStatus::Registered => StatusCategory::Unavailable,
555        ProviderStatus::Unsupported | ProviderStatus::Pending | ProviderStatus::Unknown => {
556            StatusCategory::Indeterminate
557        }
558    }
559}
560
561const fn is_registration_attempt(status: ProviderStatus) -> bool {
562    matches!(
563        status,
564        ProviderStatus::Registrable | ProviderStatus::RegistryPremium
565    )
566}
567
568impl From<ProviderStatus> for VerdictStatus {
569    fn from(value: ProviderStatus) -> Self {
570        match value {
571            ProviderStatus::Registrable => Self::Registrable,
572            ProviderStatus::RegistryPremium => Self::RegistryPremium,
573            ProviderStatus::AftermarketFixed => Self::AftermarketFixed,
574            ProviderStatus::AftermarketOffer => Self::AftermarketOffer,
575            ProviderStatus::Auction => Self::Auction,
576            ProviderStatus::Reserved => Self::Reserved,
577            ProviderStatus::Registered => Self::Registered,
578            ProviderStatus::Unsupported => Self::Unsupported,
579            ProviderStatus::Pending => Self::Pending,
580            ProviderStatus::Unknown => Self::Unknown,
581        }
582    }
583}
584
585#[allow(clippy::too_many_arguments)]
586fn make_verdict(
587    domain: &DomainName,
588    status: VerdictStatus,
589    evidence_tier: EvidenceTier,
590    reason: DecisionReason,
591    can_attempt_registration: bool,
592    considered_observation_ids: Vec<ObservationId>,
593    decisive_observation_ids: Vec<ObservationId>,
594    contradictions: Vec<Contradiction>,
595) -> Verdict {
596    Verdict {
597        domain: domain.clone(),
598        status,
599        evidence_tier,
600        reason,
601        can_attempt_registration,
602        considered_observation_ids,
603        decisive_observation_ids,
604        contradictions,
605    }
606}