1use std::path::PathBuf;
7
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10
11use crate::error::{Result, SeerError};
12use crate::status::StatusClient;
13
14#[derive(Debug, Clone, Default, Serialize, Deserialize)]
16pub struct Watchlist {
17 #[serde(default)]
18 pub domains: Vec<String>,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct WatchResult {
24 pub domain: String,
25 pub ssl_days_remaining: Option<i64>,
26 pub domain_days_remaining: Option<i64>,
27 pub registrar: Option<String>,
28 pub http_status: Option<u16>,
29 pub issues: Vec<String>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct WatchReport {
35 pub checked_at: DateTime<Utc>,
36 pub results: Vec<WatchResult>,
37 pub total: usize,
38 pub warnings: usize,
39 pub critical: usize,
40}
41
42impl Watchlist {
43 pub fn path() -> Option<PathBuf> {
45 dirs::home_dir().map(|h| h.join(".seer").join("watchlist.toml"))
46 }
47
48 pub fn load() -> Self {
55 let Some(path) = Self::path() else {
56 return Self::default();
57 };
58 Self::load_from_path(&path)
59 }
60
61 pub(crate) fn load_from_path(path: &std::path::Path) -> Self {
65 crate::fsutil::load_or_back_up(path, "watchlist", |content| {
66 toml::from_str::<Watchlist>(content).map_err(|e| e.to_string())
67 })
68 }
69
70 pub fn save(&self) -> Result<()> {
88 let path = Self::path()
89 .ok_or_else(|| SeerError::ConfigError("Cannot determine home directory".to_string()))?;
90 self.save_to_path(&path)
91 }
92
93 pub(crate) fn save_to_path(&self, path: &std::path::Path) -> Result<()> {
96 let content =
97 toml::to_string_pretty(self).map_err(|e| SeerError::ConfigError(e.to_string()))?;
98 crate::fsutil::write_atomic_owner_only(path, &content, "toml")
99 }
100
101 pub fn add(&mut self, domain: &str) -> Result<bool> {
103 let domain = crate::validation::normalize_domain(domain)?;
104 if self.domains.contains(&domain) {
105 return Ok(false);
106 }
107 self.domains.push(domain);
108 self.domains.sort();
109 Ok(true)
110 }
111
112 pub fn remove(&mut self, domain: &str) -> bool {
114 let domain =
115 crate::validation::normalize_domain(domain).unwrap_or_else(|_| domain.to_lowercase());
116 let len_before = self.domains.len();
117 self.domains.retain(|d| d != &domain);
118 self.domains.len() < len_before
119 }
120}
121
122const EXPIRY_CRITICAL_DAYS: i64 = 30;
124const DOMAIN_EXPIRY_WARN_DAYS: i64 = 90;
129const SSL_INVALID_ISSUE: &str = "SSL certificate invalid";
132const CHECK_FAILED_PREFIX: &str = "Check failed:";
133
134fn result_is_critical(r: &WatchResult) -> bool {
140 let bad_ssl = r
141 .ssl_days_remaining
142 .is_some_and(|d| d < EXPIRY_CRITICAL_DAYS);
143 let bad_domain = r
144 .domain_days_remaining
145 .is_some_and(|d| d < EXPIRY_CRITICAL_DAYS);
146 let bad_issue = r
150 .issues
151 .iter()
152 .any(|i| i == SSL_INVALID_ISSUE || i.starts_with(CHECK_FAILED_PREFIX));
153 bad_ssl || bad_domain || bad_issue
154}
155
156const DEFAULT_WATCH_CONCURRENCY: usize = 10;
158
159fn assess(domain: String, outcome: Result<crate::status::StatusResponse>) -> WatchResult {
167 let mut watch_result = WatchResult {
168 domain,
169 ssl_days_remaining: None,
170 domain_days_remaining: None,
171 registrar: None,
172 http_status: None,
173 issues: vec![],
174 };
175
176 let status = match outcome {
177 Ok(status) => status,
178 Err(e) => {
179 watch_result
180 .issues
181 .push(format!("{} {}", CHECK_FAILED_PREFIX, e));
182 return watch_result;
183 }
184 };
185
186 watch_result.http_status = status.http_status;
187
188 if status.dns_resolution.as_ref().is_some_and(|d| !d.resolves) {
191 watch_result.issues.push(format!(
192 "{} dns: domain does not resolve",
193 CHECK_FAILED_PREFIX
194 ));
195 }
196 for err in &status.errors {
197 match err.check.as_str() {
198 "dns" => watch_result
199 .issues
200 .push(format!("{} dns: {}", CHECK_FAILED_PREFIX, err.message)),
201 "http" => watch_result
204 .issues
205 .push(format!("HTTP check failed: {}", err.message)),
206 "ssl" => watch_result
207 .issues
208 .push(format!("SSL check failed: {}", err.message)),
209 _ => {}
210 }
211 }
212
213 if let Some(ref cert) = status.certificate {
214 watch_result.ssl_days_remaining = Some(cert.days_until_expiry);
215 if cert.days_until_expiry < EXPIRY_CRITICAL_DAYS {
216 watch_result
217 .issues
218 .push(format!("SSL expires in {} days", cert.days_until_expiry));
219 }
220 if !cert.is_valid {
221 watch_result.issues.push(SSL_INVALID_ISSUE.to_string());
222 }
223 }
224
225 if let Some(ref exp) = status.domain_expiration {
226 watch_result.domain_days_remaining = Some(exp.days_until_expiry);
227 watch_result.registrar = exp.registrar.clone();
228 if exp.days_until_expiry < DOMAIN_EXPIRY_WARN_DAYS {
229 watch_result
230 .issues
231 .push(format!("Domain expires in {} days", exp.days_until_expiry));
232 }
233 }
234
235 if let Some(status_code) = status.http_status {
236 if !(200..300).contains(&status_code) {
237 watch_result
238 .issues
239 .push(format!("HTTP status {}", status_code));
240 }
241 }
242
243 watch_result
244}
245
246pub async fn check_watchlist(domains: &[String]) -> WatchReport {
252 check_watchlist_with(domains, StatusClient::new(), DEFAULT_WATCH_CONCURRENCY).await
253}
254
255pub async fn check_watchlist_with_config(
258 domains: &[String],
259 config: &crate::config::SeerConfig,
260) -> WatchReport {
261 check_watchlist_with(
262 domains,
263 StatusClient::from_config(config),
264 config.bulk.concurrency,
265 )
266 .await
267}
268
269pub async fn check_watchlist_with(
271 domains: &[String],
272 client: StatusClient,
273 concurrency: usize,
274) -> WatchReport {
275 use futures::stream::{self, StreamExt};
276
277 let client = std::sync::Arc::new(client);
283
284 let results: Vec<WatchResult> = stream::iter(domains.iter().cloned())
285 .map(|domain| {
286 let client = client.clone();
287 async move {
288 let outcome = client.check(&domain).await;
289 assess(domain, outcome)
290 }
291 })
292 .buffer_unordered(concurrency.max(1))
293 .collect()
294 .await;
295
296 let total = results.len();
297 let critical = results.iter().filter(|r| result_is_critical(r)).count();
302 let warnings = results.iter().filter(|r| !r.issues.is_empty()).count();
303
304 WatchReport {
305 checked_at: Utc::now(),
306 results,
307 total,
308 warnings,
309 critical,
310 }
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316
317 fn status_with(
318 dns: Option<crate::status::DnsResolution>,
319 errors: &[(&str, &str)],
320 ) -> crate::status::StatusResponse {
321 let mut status = crate::status::StatusResponse::new("example.com".to_string());
322 status.dns_resolution = dns;
323 status.errors = errors
324 .iter()
325 .map(|(check, message)| crate::status::StatusError {
326 check: check.to_string(),
327 message: message.to_string(),
328 })
329 .collect();
330 status
331 }
332
333 fn resolution(resolves: bool) -> crate::status::DnsResolution {
334 crate::status::DnsResolution {
335 a_records: vec![],
336 aaaa_records: vec![],
337 cname_target: None,
338 nameservers: vec![],
339 resolves,
340 }
341 }
342
343 #[test]
344 fn a_domain_that_stopped_resolving_is_critical() {
345 let r = assess(
348 "example.com".to_string(),
349 Ok(status_with(Some(resolution(false)), &[])),
350 );
351 assert!(result_is_critical(&r), "issues: {:?}", r.issues);
352 let r = assess(
353 "example.com".to_string(),
354 Ok(status_with(None, &[("dns", "DNS lookup timed out")])),
355 );
356 assert!(result_is_critical(&r), "issues: {:?}", r.issues);
357 }
358
359 #[test]
360 fn unreachable_web_endpoints_are_warnings() {
361 let r = assess(
362 "example.com".to_string(),
363 Ok(status_with(
364 Some(resolution(true)),
365 &[("http", "connection refused"), ("ssl", "handshake failed")],
366 )),
367 );
368 assert_eq!(r.issues.len(), 2, "issues: {:?}", r.issues);
369 assert!(!r.issues.is_empty());
370 assert!(!result_is_critical(&r));
372 }
373
374 #[test]
375 fn a_healthy_domain_has_no_issues() {
376 let r = assess(
377 "example.com".to_string(),
378 Ok(status_with(Some(resolution(true)), &[])),
379 );
380 assert!(r.issues.is_empty(), "issues: {:?}", r.issues);
381 }
382
383 #[test]
384 fn test_watchlist_default() {
385 let wl = Watchlist::default();
386 assert!(wl.domains.is_empty());
387 }
388
389 #[test]
390 fn test_watchlist_add_remove() {
391 let mut wl = Watchlist::default();
392 assert!(wl.add("example.com").unwrap());
393 assert!(!wl.add("example.com").unwrap()); assert_eq!(wl.domains.len(), 1);
395
396 assert!(wl.add("test.org").unwrap());
397 assert_eq!(wl.domains.len(), 2);
398 assert_eq!(wl.domains[0], "example.com");
400 assert_eq!(wl.domains[1], "test.org");
401
402 assert!(wl.remove("example.com"));
403 assert!(!wl.remove("example.com")); assert_eq!(wl.domains.len(), 1);
405 }
406
407 #[test]
408 fn test_watchlist_add_normalizes_case() {
409 let mut wl = Watchlist::default();
410 wl.add("EXAMPLE.COM").unwrap();
411 assert_eq!(wl.domains[0], "example.com");
412 }
413
414 #[test]
415 fn test_watchlist_serialization() {
416 let mut wl = Watchlist::default();
417 wl.add("a.com").unwrap();
418 wl.add("b.org").unwrap();
419 let toml_str = toml::to_string_pretty(&wl).unwrap();
420 assert!(toml_str.contains("a.com"));
421 assert!(toml_str.contains("b.org"));
422
423 let parsed: Watchlist = toml::from_str(&toml_str).unwrap();
424 assert_eq!(parsed.domains.len(), 2);
425 }
426
427 fn unique_temp_watchlist_path(tag: &str) -> PathBuf {
429 let mut dir = std::env::temp_dir();
430 dir.push(format!(
431 "seer-watchlist-test-{}-{}",
432 tag,
433 std::process::id()
434 ));
435 let _ = std::fs::create_dir_all(&dir);
436 dir.push("watchlist.toml");
437 dir
438 }
439
440 #[test]
441 fn load_from_path_returns_default_and_backs_up_corrupt_file() {
442 let path = unique_temp_watchlist_path("corrupt");
443 let backup = path.with_extension("corrupt");
444
445 let _ = std::fs::remove_file(&path);
446 let _ = std::fs::remove_file(&backup);
447
448 std::fs::write(&path, b"domains = not-an-array-\n").expect("seed corrupt watchlist file");
450
451 let loaded = Watchlist::load_from_path(&path);
452 assert!(
453 loaded.domains.is_empty(),
454 "corrupt watchlist must load as empty default"
455 );
456 assert!(
457 !path.exists(),
458 "original corrupt file should have been renamed away"
459 );
460 assert!(
461 backup.exists(),
462 "backup .corrupt file should exist at {}",
463 backup.display()
464 );
465
466 let _ = std::fs::remove_file(&backup);
467 if let Some(parent) = path.parent() {
468 let _ = std::fs::remove_dir_all(parent);
469 }
470 }
471
472 #[test]
473 fn load_from_path_returns_default_when_missing() {
474 let path = unique_temp_watchlist_path("missing");
475 let _ = std::fs::remove_file(&path);
476
477 let loaded = Watchlist::load_from_path(&path);
478 assert!(loaded.domains.is_empty());
479
480 if let Some(parent) = path.parent() {
481 let _ = std::fs::remove_dir_all(parent);
482 }
483 }
484
485 #[test]
486 fn concurrent_saves_do_not_corrupt_the_file() {
487 let path = unique_temp_watchlist_path("concurrent");
493 let _ = std::fs::remove_file(&path);
494
495 let mut a = Watchlist::default();
496 a.add("a.example").unwrap();
497 let mut b = Watchlist::default();
498 for i in 0..100 {
499 b.add(&format!("b{i}.example")).unwrap();
500 }
501
502 let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
503 let spawn_saver =
504 |wl: Watchlist, path: PathBuf, barrier: std::sync::Arc<std::sync::Barrier>| {
505 std::thread::spawn(move || {
506 for _ in 0..50 {
507 barrier.wait();
508 wl.save_to_path(&path).expect("concurrent save failed");
509 }
510 })
511 };
512 let ta = spawn_saver(a, path.clone(), barrier.clone());
513 let tb = spawn_saver(b, path.clone(), barrier);
514 ta.join().expect("thread A panicked");
515 tb.join().expect("thread B panicked");
516
517 let content = std::fs::read_to_string(&path).expect("saved file exists");
519 toml::from_str::<Watchlist>(&content)
520 .expect("concurrently saved watchlist must parse (no torn rename)");
521
522 if let Some(parent) = path.parent() {
523 let _ = std::fs::remove_dir_all(parent);
524 }
525 }
526
527 fn result_with(ssl: Option<i64>, domain: Option<i64>, issues: &[&str]) -> WatchResult {
528 WatchResult {
529 domain: "x.test".to_string(),
530 ssl_days_remaining: ssl,
531 domain_days_remaining: domain,
532 registrar: None,
533 http_status: Some(200),
534 issues: issues.iter().map(|s| s.to_string()).collect(),
535 }
536 }
537
538 #[test]
539 fn critical_uses_explicit_expiry_bands() {
540 assert!(!result_is_critical(&result_with(
543 None,
544 Some(60),
545 &["Domain expires in 60 days"]
546 )));
547 assert!(result_is_critical(&result_with(
548 None,
549 Some(20),
550 &["Domain expires in 20 days"]
551 )));
552 assert!(result_is_critical(&result_with(Some(10), None, &[])));
554 assert!(result_is_critical(&result_with(
555 Some(200),
556 None,
557 &["SSL certificate invalid"]
558 )));
559 assert!(result_is_critical(&result_with(
561 None,
562 None,
563 &["Check failed: connection refused"]
564 )));
565 }
566
567 #[test]
568 fn critical_predicate_is_structured_not_freetext() {
569 let r = result_with(
573 Some(200),
574 Some(200),
575 &["Note: a prior validation failed last week"],
576 );
577 assert!(
578 !result_is_critical(&r),
579 "free-text 'failed' must not trip the critical predicate"
580 );
581 }
582
583 #[test]
584 fn test_watch_result_serialization() {
585 let result = WatchResult {
586 domain: "example.com".to_string(),
587 ssl_days_remaining: Some(45),
588 domain_days_remaining: Some(120),
589 registrar: Some("Test Registrar".to_string()),
590 http_status: Some(200),
591 issues: vec![],
592 };
593 let json = serde_json::to_string(&result).unwrap();
594 assert!(json.contains("example.com"));
595 assert!(json.contains("45"));
596 }
597}