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#[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#[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
148pub 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}