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
12pub(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
22pub(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
32pub 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 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(tag = "reason", rename_all = "kebab-case")]
84pub enum Reason {
85 NoService,
86 RateLimited,
87 ServerError {
89 status: u16,
90 },
91 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 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 #[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 #[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 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#[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}