Skip to main content

reserve_core/lookup/
outcome.rs

1use std::fmt;
2use std::time::Duration;
3
4use serde::{Deserialize, Serialize};
5
6/// @docgen Remote text reaches a terminal and a JSON file, so control bytes could rewrite the screen or hide a line.
7pub(crate) fn scrub(raw: &str) -> String {
8    raw.chars()
9        .filter(|c| !c.is_control() && !reorders_text(*c))
10        .take(200)
11        .collect::<String>()
12        .trim()
13        .to_owned()
14}
15
16/// @docgen A bidi override is no control character, yet it reverses the line and can make a taken name read as free.
17/// @docgen Bengali, Persian and several Indian scripts use these to shape letters, so a typed name that carries one means it.
18pub const fn shapes_letters(c: char) -> bool {
19    matches!(c, '\u{200c}' | '\u{200d}')
20}
21
22pub const fn reorders_text(c: char) -> bool {
23    matches!(c,
24        '\u{00ad}'
25        | '\u{200b}'..='\u{200f}'
26        | '\u{202a}'..='\u{202e}'
27        | '\u{2060}'..='\u{2064}'
28        | '\u{2066}'..='\u{2069}'
29        | '\u{feff}')
30}
31
32use crate::tld::Suffix;
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "kebab-case")]
36pub enum Source {
37    Registry,
38    Text,
39    Dns,
40}
41
42impl Source {
43    #[must_use]
44    pub const fn label(self) -> &'static str {
45        match self {
46            Self::Registry => "registry",
47            Self::Text => "text",
48            Self::Dns => "dns",
49        }
50    }
51
52    /// @docgen Only the registry settles it: DNS proves a name is taken but never free, and the text service is pattern matching.
53    #[must_use]
54    pub const fn is_authoritative(self) -> bool {
55        matches!(self, Self::Registry)
56    }
57}
58
59impl fmt::Display for Source {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        f.write_str(self.label())
62    }
63}
64
65/// @docgen Every one of these is reported as unknown and never folded into available.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(tag = "reason", rename_all = "kebab-case")]
68pub enum Reason {
69    NoService,
70    RateLimited,
71    /// @docgen A registry fault is not pushback, and calling it rate limiting sends the user away to wait instead of reporting an outage.
72    ServerError {
73        status: u16,
74    },
75    /// @docgen The registry answered and refused the question, which is understood perfectly and must not read as a garbled reply.
76    Declined {
77        detail: String,
78    },
79    Blocked,
80    TimedOut,
81    Unreachable,
82    Malformed {
83        detail: String,
84    },
85    WrongSubject {
86        answered_about: String,
87    },
88    /// @docgen The extension exists but only a closed group may register in it, so availability is not a meaningful question.
89    NotRegistrable,
90}
91
92impl Reason {
93    #[must_use]
94    pub const fn label(&self) -> &'static str {
95        match self {
96            Self::NoService => "no registry service",
97            Self::RateLimited => "rate limited",
98            Self::ServerError { .. } => "registry error",
99            Self::Declined { .. } => "registry declined",
100            Self::Blocked => "access refused",
101            Self::TimedOut => "timed out",
102            Self::Unreachable => "could not connect",
103            Self::Malformed { .. } => "answer not understood",
104            Self::WrongSubject { .. } => "answered about another name",
105            Self::NotRegistrable => "not publicly registrable",
106        }
107    }
108
109    #[must_use]
110    pub const fn is_retryable(&self) -> bool {
111        matches!(
112            self,
113            Self::RateLimited
114                | Self::ServerError { .. }
115                | Self::Declined { .. }
116                | Self::TimedOut
117                | Self::Unreachable
118                | Self::Blocked
119        )
120    }
121}
122
123impl fmt::Display for Reason {
124    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125        match self {
126            Self::Malformed { detail } | Self::Declined { detail } => {
127                write!(f, "{}: {detail}", self.label())
128            }
129            Self::WrongSubject { answered_about } => {
130                write!(f, "{}: {answered_about}", self.label())
131            }
132            other => f.write_str(other.label()),
133        }
134    }
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138#[serde(tag = "status", rename_all = "kebab-case")]
139pub enum Status {
140    Available,
141    Taken,
142    Unknown(Reason),
143}
144
145impl Status {
146    #[must_use]
147    pub const fn is_available(&self) -> bool {
148        matches!(self, Self::Available)
149    }
150
151    #[must_use]
152    pub const fn is_unknown(&self) -> bool {
153        matches!(self, Self::Unknown(_))
154    }
155
156    /// @docgen Meaning never rides on color alone, so the symbol carries it too.
157    #[must_use]
158    pub const fn mark(&self) -> char {
159        match self {
160            Self::Available => '+',
161            Self::Taken => '-',
162            Self::Unknown(_) => '?',
163        }
164    }
165
166    #[must_use]
167    pub const fn label(&self) -> &'static str {
168        match self {
169            Self::Available => "AVAILABLE",
170            Self::Taken => "TAKEN",
171            Self::Unknown(_) => "UNKNOWN",
172        }
173    }
174}
175
176#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
177pub struct Finding {
178    pub domain: String,
179    pub name: String,
180    pub suffix: Suffix,
181    pub status: Status,
182    pub source: Option<Source>,
183    pub elapsed: Duration,
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub responder: Option<String>,
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub registration: Option<crate::lookup::registration::Registration>,
188}
189
190impl Finding {
191    #[must_use]
192    pub fn unknown(name: &str, suffix: &Suffix, reason: Reason, elapsed: Duration) -> Self {
193        Self {
194            domain: format!("{name}.{suffix}"),
195            name: name.to_owned(),
196            suffix: suffix.clone(),
197            status: Status::Unknown(reason),
198            source: None,
199            elapsed,
200            responder: None,
201            registration: None,
202        }
203    }
204
205    #[must_use]
206    /// @docgen A name whose extension is not in the catalog still gets a row, so it is never dropped from the table.
207    pub fn unrecognized(domain: &str) -> Self {
208        let suffix = domain.rsplit_once('.').map_or(domain, |(_, tail)| tail);
209        Self {
210            domain: domain.to_owned(),
211            name: domain.to_owned(),
212            suffix: Suffix::from_raw(suffix),
213            status: Status::Unknown(Reason::NoService),
214            source: None,
215            elapsed: Duration::ZERO,
216            responder: None,
217            registration: None,
218        }
219    }
220
221    #[must_use]
222    pub const fn is_available(&self) -> bool {
223        self.status.is_available()
224    }
225}
226
227#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
228pub struct Tally {
229    pub available: usize,
230    pub taken: usize,
231    pub unknown: usize,
232}
233
234impl Tally {
235    #[must_use]
236    pub fn of(findings: &[Finding]) -> Self {
237        let mut tally = Self::default();
238        for finding in findings {
239            match finding.status {
240                Status::Available => tally.available += 1,
241                Status::Taken => tally.taken += 1,
242                Status::Unknown(_) => tally.unknown += 1,
243            }
244        }
245        tally
246    }
247
248    #[must_use]
249    pub const fn checked(&self) -> usize {
250        self.available + self.taken + self.unknown
251    }
252
253    #[must_use]
254    pub const fn has_available(&self) -> bool {
255        self.available > 0
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    fn suffix() -> Suffix {
264        Suffix::parse("com").expect("com parses")
265    }
266
267    #[test]
268    fn only_the_registry_settles_the_question_on_its_own() {
269        assert!(Source::Registry.is_authoritative());
270        assert!(!Source::Dns.is_authoritative());
271        assert!(!Source::Text.is_authoritative());
272    }
273
274    #[test]
275    fn every_unanswered_lookup_is_unknown_and_never_available() {
276        let reasons = [
277            Reason::NoService,
278            Reason::RateLimited,
279            Reason::ServerError { status: 500 },
280            Reason::Blocked,
281            Reason::TimedOut,
282            Reason::Unreachable,
283            Reason::NotRegistrable,
284            Reason::Malformed {
285                detail: "bad json".to_owned(),
286            },
287            Reason::WrongSubject {
288                answered_about: "other.com".to_owned(),
289            },
290        ];
291        for reason in reasons {
292            let status = Status::Unknown(reason);
293            assert!(!status.is_available(), "{status:?} must not read as free");
294            assert!(status.is_unknown());
295            assert_eq!(status.mark(), '?');
296        }
297    }
298
299    #[test]
300    fn a_throttled_lookup_is_retryable_but_a_missing_service_is_not() {
301        assert!(Reason::RateLimited.is_retryable());
302        assert!(Reason::ServerError { status: 503 }.is_retryable());
303        assert!(Reason::TimedOut.is_retryable());
304        assert!(Reason::Unreachable.is_retryable());
305        assert!(!Reason::NoService.is_retryable());
306        assert!(!Reason::NotRegistrable.is_retryable());
307        assert!(
308            !Reason::Malformed {
309                detail: String::new()
310            }
311            .is_retryable()
312        );
313    }
314
315    #[test]
316    fn the_status_mark_carries_meaning_without_color() {
317        assert_eq!(Status::Available.mark(), '+');
318        assert_eq!(Status::Taken.mark(), '-');
319        assert_eq!(Status::Unknown(Reason::TimedOut).mark(), '?');
320        assert_ne!(Status::Available.label(), Status::Taken.label());
321    }
322
323    #[test]
324    fn an_unknown_finding_reports_the_reason_in_its_text() {
325        let finding = Finding::unknown(
326            "example",
327            &suffix(),
328            Reason::RateLimited,
329            Duration::from_millis(20),
330        );
331        assert_eq!(finding.domain, "example.com");
332        assert!(!finding.is_available());
333        match finding.status {
334            Status::Unknown(reason) => assert_eq!(reason.to_string(), "rate limited"),
335            other => panic!("expected unknown, got {other:?}"),
336        }
337    }
338
339    #[test]
340    fn an_unreadable_answer_keeps_its_detail_in_the_message() {
341        let reason = Reason::Malformed {
342            detail: "truncated body".to_owned(),
343        };
344        assert!(reason.to_string().contains("truncated body"));
345    }
346
347    #[test]
348    fn the_tally_counts_each_class_and_never_double_counts() {
349        let findings = vec![
350            Finding {
351                domain: "a.com".to_owned(),
352                name: "a".to_owned(),
353                suffix: suffix(),
354                status: Status::Available,
355                source: Some(Source::Registry),
356                elapsed: Duration::ZERO,
357                responder: None,
358                registration: None,
359            },
360            Finding {
361                domain: "b.com".to_owned(),
362                name: "b".to_owned(),
363                suffix: suffix(),
364                status: Status::Taken,
365                source: Some(Source::Registry),
366                elapsed: Duration::ZERO,
367                responder: None,
368                registration: None,
369            },
370            Finding::unknown("c", &suffix(), Reason::TimedOut, Duration::ZERO),
371        ];
372
373        let tally = Tally::of(&findings);
374        assert_eq!(tally.available, 1);
375        assert_eq!(tally.taken, 1);
376        assert_eq!(tally.unknown, 1);
377        assert_eq!(tally.checked(), 3);
378        assert!(tally.has_available());
379    }
380
381    #[test]
382    fn a_run_with_no_free_names_reports_nothing_found() {
383        let tally = Tally {
384            available: 0,
385            taken: 5,
386            unknown: 2,
387        };
388        assert!(!tally.has_available());
389        assert_eq!(tally.checked(), 7);
390    }
391
392    #[test]
393    fn an_empty_run_tallies_to_zero() {
394        let tally = Tally::of(&[]);
395        assert_eq!(tally, Tally::default());
396        assert_eq!(tally.checked(), 0);
397        assert!(!tally.has_available());
398    }
399
400    #[test]
401    fn a_bidi_override_cannot_reverse_a_line_the_way_a_control_byte_would() {
402        let hostile = "evil\u{202e}moc.elpmaxe\u{202c}";
403        let clean = scrub(hostile);
404        assert!(!clean.contains('\u{202e}'), "an override must not survive");
405        assert!(!clean.contains('\u{202c}'));
406        assert!(clean.contains("evil"), "the readable text still shows");
407
408        for mark in ['\u{200b}', '\u{200f}', '\u{2066}', '\u{feff}', '\u{00ad}'] {
409            let raw = format!("a{mark}b");
410            assert_eq!(scrub(&raw), "ab", "{mark:?} must not survive");
411        }
412    }
413}