1use std::fmt;
2use std::time::Duration;
3
4use serde::{Deserialize, Serialize};
5
6pub(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
16pub 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 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(tag = "reason", rename_all = "kebab-case")]
68pub enum Reason {
69 NoService,
70 RateLimited,
71 ServerError {
73 status: u16,
74 },
75 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 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 #[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 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}