Skip to main content

reserve_core/lookup/
outcome.rs

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