Skip to main content

seer_core/
watchlist.rs

1//! Domain watchlist for monitoring expiration and health.
2//!
3//! Loads a list of domains from `~/.seer/watchlist.toml` and checks their
4//! SSL certificates, domain expiration, and HTTP status.
5
6use std::path::PathBuf;
7
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10
11use crate::error::{Result, SeerError};
12use crate::status::StatusClient;
13
14/// Persistent list of domains to monitor.
15#[derive(Debug, Clone, Default, Serialize, Deserialize)]
16pub struct Watchlist {
17    #[serde(default)]
18    pub domains: Vec<String>,
19}
20
21/// Status result for a single watched domain.
22#[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/// Aggregated report from checking all watched domains.
33#[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    /// Returns the path to the watchlist file (`~/.seer/watchlist.toml`).
44    pub fn path() -> Option<PathBuf> {
45        dirs::home_dir().map(|h| h.join(".seer").join("watchlist.toml"))
46    }
47
48    /// Loads the watchlist from disk, returning an empty list on any failure.
49    ///
50    /// When the file exists but fails to parse, it is renamed to
51    /// `<path>.corrupt` (preserving the user's data for recovery/forensics)
52    /// and a warning is logged — previously the file was silently
53    /// overwritten on the next save, dropping the user's watchlist.
54    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    /// Like [`Self::load`] but reads from an explicit path. Split out so
62    /// tests can exercise the corrupt-file handling without depending on
63    /// the real `~/.seer/watchlist.toml` location.
64    pub(crate) fn load_from_path(path: &std::path::Path) -> Self {
65        if !path.exists() {
66            return Self::default();
67        }
68        match std::fs::read_to_string(path) {
69            Ok(content) => match toml::from_str::<Watchlist>(&content) {
70                Ok(w) => w,
71                Err(e) => {
72                    let backup = path.with_extension("corrupt");
73                    if let Err(rename_err) = std::fs::rename(path, &backup) {
74                        tracing::error!(
75                            path = %path.display(),
76                            error = %rename_err,
77                            "failed to back up corrupt watchlist",
78                        );
79                    } else {
80                        tracing::warn!(
81                            path = %path.display(),
82                            backup = %backup.display(),
83                            error = %e,
84                            "watchlist file corrupt; moved to backup",
85                        );
86                    }
87                    Watchlist::default()
88                }
89            },
90            Err(_) => Self::default(),
91        }
92    }
93
94    /// Persists the watchlist to disk via write-and-rename so a crash mid-write
95    /// cannot leave the file truncated (the next `load()` would see corrupt
96    /// TOML and silently fall back to the default empty watchlist, losing
97    /// the user's domains). Mirrors `LookupHistory::save`.
98    ///
99    /// The temp filename is unique per call (PID + process-wide counter, see
100    /// `crate::fsutil`) so concurrent saves — whether from two `seer`
101    /// processes or two tasks in one process — never write to the same
102    /// intermediate path and race each other's `rename`s.
103    ///
104    /// # Concurrency
105    ///
106    /// As with [`crate::history::LookupHistory::save`], the write is atomic but
107    /// the load → add/remove → save cycle is not cross-process locked: two
108    /// concurrent writers can lose one side's add/remove (last-writer-wins). No
109    /// corruption occurs. A cross-process advisory lock would close the window;
110    /// it is omitted to avoid a new dependency for a low-frequency edge case.
111    pub fn save(&self) -> Result<()> {
112        let path = Self::path()
113            .ok_or_else(|| SeerError::ConfigError("Cannot determine home directory".to_string()))?;
114        self.save_to_path(&path)
115    }
116
117    /// Like [`Self::save`] but writes to an explicit path. Split out so tests
118    /// can exercise the atomic-save path without touching `~/.seer`.
119    pub(crate) fn save_to_path(&self, path: &std::path::Path) -> Result<()> {
120        let content =
121            toml::to_string_pretty(self).map_err(|e| SeerError::ConfigError(e.to_string()))?;
122        crate::fsutil::write_atomic_owner_only(path, &content, "toml")
123    }
124
125    /// Adds a domain to the watchlist. Returns `Ok(true)` if the domain was newly added.
126    pub fn add(&mut self, domain: &str) -> Result<bool> {
127        let domain = crate::validation::normalize_domain(domain)?;
128        if self.domains.contains(&domain) {
129            return Ok(false);
130        }
131        self.domains.push(domain);
132        self.domains.sort();
133        Ok(true)
134    }
135
136    /// Removes a domain from the watchlist. Returns `true` if the domain was present.
137    pub fn remove(&mut self, domain: &str) -> bool {
138        let domain =
139            crate::validation::normalize_domain(domain).unwrap_or_else(|_| domain.to_lowercase());
140        let len_before = self.domains.len();
141        self.domains.retain(|d| d != &domain);
142        self.domains.len() < len_before
143    }
144}
145
146/// SSL or domain-registration expiry within this many days is *critical*.
147const EXPIRY_CRITICAL_DAYS: i64 = 30;
148/// Domain-registration expiry within this many days surfaces an informational
149/// (warning-band) issue even before it becomes critical. SSL uses only the
150/// critical band. Defined as named constants so the issue-push thresholds and
151/// the critical tally are a single source of truth (issue #57).
152const DOMAIN_EXPIRY_WARN_DAYS: i64 = 90;
153/// Exact issue strings this module emits, so the critical predicate can match
154/// them structurally rather than scanning free text for "invalid"/"failed".
155const SSL_INVALID_ISSUE: &str = "SSL certificate invalid";
156const CHECK_FAILED_PREFIX: &str = "Check failed:";
157
158/// Returns true if a checked result is *critical* (vs merely a warning): an SSL
159/// or registration expiry within [`EXPIRY_CRITICAL_DAYS`], an invalid SSL
160/// certificate, or a failed check. Uses the numeric day fields and the exact
161/// issue markers this module emits — not a locale/text-fragile substring scan
162/// for "invalid"/"failed" (issue #57).
163fn result_is_critical(r: &WatchResult) -> bool {
164    let bad_ssl = r
165        .ssl_days_remaining
166        .is_some_and(|d| d < EXPIRY_CRITICAL_DAYS);
167    let bad_domain = r
168        .domain_days_remaining
169        .is_some_and(|d| d < EXPIRY_CRITICAL_DAYS);
170    // Match the exact markers this module emits, not arbitrary free text, so a
171    // benign issue line that happens to contain "failed"/"invalid" can't be
172    // miscounted as critical.
173    let bad_issue = r
174        .issues
175        .iter()
176        .any(|i| i == SSL_INVALID_ISSUE || i.starts_with(CHECK_FAILED_PREFIX));
177    bad_ssl || bad_domain || bad_issue
178}
179
180/// Checks all given domains concurrently and produces a [`WatchReport`].
181pub async fn check_watchlist(domains: &[String]) -> WatchReport {
182    use futures::stream::{self, StreamExt};
183
184    // Each per-domain future owns its `client` (via `Arc`) and `domain`
185    // (owned `String`) so the `buffer_unordered` futures are `Send + 'static`
186    // and the whole `check_watchlist` future can be used from `tokio::spawn`
187    // (e.g. the TUI). Borrowing `&client`/`&String` here makes the closure fail
188    // the higher-ranked `FnOnce` bound `tokio::spawn` requires.
189    let client = std::sync::Arc::new(StatusClient::new());
190
191    let results: Vec<WatchResult> = stream::iter(domains.iter().cloned())
192        .map(|domain| {
193            let client = client.clone();
194            async move {
195                let mut watch_result = WatchResult {
196                    domain: domain.clone(),
197                    ssl_days_remaining: None,
198                    domain_days_remaining: None,
199                    registrar: None,
200                    http_status: None,
201                    issues: vec![],
202                };
203
204                match client.check(&domain).await {
205                    Ok(status) => {
206                        watch_result.http_status = status.http_status;
207
208                        if let Some(ref cert) = status.certificate {
209                            watch_result.ssl_days_remaining = Some(cert.days_until_expiry);
210                            if cert.days_until_expiry < EXPIRY_CRITICAL_DAYS {
211                                watch_result.issues.push(format!(
212                                    "SSL expires in {} days",
213                                    cert.days_until_expiry
214                                ));
215                            }
216                            if !cert.is_valid {
217                                watch_result.issues.push(SSL_INVALID_ISSUE.to_string());
218                            }
219                        }
220
221                        if let Some(ref exp) = status.domain_expiration {
222                            watch_result.domain_days_remaining = Some(exp.days_until_expiry);
223                            watch_result.registrar = exp.registrar.clone();
224                            if exp.days_until_expiry < DOMAIN_EXPIRY_WARN_DAYS {
225                                watch_result.issues.push(format!(
226                                    "Domain expires in {} days",
227                                    exp.days_until_expiry
228                                ));
229                            }
230                        }
231
232                        if let Some(status_code) = status.http_status {
233                            if !(200..300).contains(&status_code) {
234                                watch_result
235                                    .issues
236                                    .push(format!("HTTP status {}", status_code));
237                            }
238                        }
239                    }
240                    Err(e) => {
241                        watch_result
242                            .issues
243                            .push(format!("{} {}", CHECK_FAILED_PREFIX, e));
244                    }
245                }
246
247                watch_result
248            }
249        })
250        .buffer_unordered(10)
251        .collect()
252        .await;
253
254    let total = results.len();
255    // Critical vs warning use explicit, shared bands (see `result_is_critical`
256    // and the EXPIRY_* constants) so the tally lines up with the human-visible
257    // issue lines: a registration expiry in the 30..90-day warning band shows
258    // an issue and counts as a warning, while < 30 days counts as critical.
259    let critical = results.iter().filter(|r| result_is_critical(r)).count();
260    let warnings = results.iter().filter(|r| !r.issues.is_empty()).count();
261
262    WatchReport {
263        checked_at: Utc::now(),
264        results,
265        total,
266        warnings,
267        critical,
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    #[test]
276    fn test_watchlist_default() {
277        let wl = Watchlist::default();
278        assert!(wl.domains.is_empty());
279    }
280
281    #[test]
282    fn test_watchlist_add_remove() {
283        let mut wl = Watchlist::default();
284        assert!(wl.add("example.com").unwrap());
285        assert!(!wl.add("example.com").unwrap()); // duplicate
286        assert_eq!(wl.domains.len(), 1);
287
288        assert!(wl.add("test.org").unwrap());
289        assert_eq!(wl.domains.len(), 2);
290        // Should be sorted
291        assert_eq!(wl.domains[0], "example.com");
292        assert_eq!(wl.domains[1], "test.org");
293
294        assert!(wl.remove("example.com"));
295        assert!(!wl.remove("example.com")); // already removed
296        assert_eq!(wl.domains.len(), 1);
297    }
298
299    #[test]
300    fn test_watchlist_add_normalizes_case() {
301        let mut wl = Watchlist::default();
302        wl.add("EXAMPLE.COM").unwrap();
303        assert_eq!(wl.domains[0], "example.com");
304    }
305
306    #[test]
307    fn test_watchlist_serialization() {
308        let mut wl = Watchlist::default();
309        wl.add("a.com").unwrap();
310        wl.add("b.org").unwrap();
311        let toml_str = toml::to_string_pretty(&wl).unwrap();
312        assert!(toml_str.contains("a.com"));
313        assert!(toml_str.contains("b.org"));
314
315        let parsed: Watchlist = toml::from_str(&toml_str).unwrap();
316        assert_eq!(parsed.domains.len(), 2);
317    }
318
319    /// Creates a unique temporary file path for a load-from-disk test.
320    fn unique_temp_watchlist_path(tag: &str) -> PathBuf {
321        let mut dir = std::env::temp_dir();
322        dir.push(format!(
323            "seer-watchlist-test-{}-{}",
324            tag,
325            std::process::id()
326        ));
327        let _ = std::fs::create_dir_all(&dir);
328        dir.push("watchlist.toml");
329        dir
330    }
331
332    #[test]
333    fn load_from_path_returns_default_and_backs_up_corrupt_file() {
334        let path = unique_temp_watchlist_path("corrupt");
335        let backup = path.with_extension("corrupt");
336
337        let _ = std::fs::remove_file(&path);
338        let _ = std::fs::remove_file(&backup);
339
340        // TOML parsers reject stray garbage on the value side of `=`.
341        std::fs::write(&path, b"domains = not-an-array-\n").expect("seed corrupt watchlist file");
342
343        let loaded = Watchlist::load_from_path(&path);
344        assert!(
345            loaded.domains.is_empty(),
346            "corrupt watchlist must load as empty default"
347        );
348        assert!(
349            !path.exists(),
350            "original corrupt file should have been renamed away"
351        );
352        assert!(
353            backup.exists(),
354            "backup .corrupt file should exist at {}",
355            backup.display()
356        );
357
358        let _ = std::fs::remove_file(&backup);
359        if let Some(parent) = path.parent() {
360            let _ = std::fs::remove_dir_all(parent);
361        }
362    }
363
364    #[test]
365    fn load_from_path_returns_default_when_missing() {
366        let path = unique_temp_watchlist_path("missing");
367        let _ = std::fs::remove_file(&path);
368
369        let loaded = Watchlist::load_from_path(&path);
370        assert!(loaded.domains.is_empty());
371
372        if let Some(parent) = path.parent() {
373            let _ = std::fs::remove_dir_all(parent);
374        }
375    }
376
377    #[test]
378    fn concurrent_saves_do_not_corrupt_the_file() {
379        // Two same-process writers saving to the same target concurrently:
380        // with a shared (PID-only) temp path one writer truncates the other's
381        // finished bytes and the loser's rename fails (or publishes a torn
382        // file). With per-call temp paths every save succeeds and the last
383        // rename wins with a complete file. Mirrors the history.rs test.
384        let path = unique_temp_watchlist_path("concurrent");
385        let _ = std::fs::remove_file(&path);
386
387        let mut a = Watchlist::default();
388        a.add("a.example").unwrap();
389        let mut b = Watchlist::default();
390        for i in 0..100 {
391            b.add(&format!("b{i}.example")).unwrap();
392        }
393
394        let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
395        let spawn_saver =
396            |wl: Watchlist, path: PathBuf, barrier: std::sync::Arc<std::sync::Barrier>| {
397                std::thread::spawn(move || {
398                    for _ in 0..50 {
399                        barrier.wait();
400                        wl.save_to_path(&path).expect("concurrent save failed");
401                    }
402                })
403            };
404        let ta = spawn_saver(a, path.clone(), barrier.clone());
405        let tb = spawn_saver(b, path.clone(), barrier);
406        ta.join().expect("thread A panicked");
407        tb.join().expect("thread B panicked");
408
409        // Whichever writer won the last rename, the file must be complete.
410        let content = std::fs::read_to_string(&path).expect("saved file exists");
411        toml::from_str::<Watchlist>(&content)
412            .expect("concurrently saved watchlist must parse (no torn rename)");
413
414        if let Some(parent) = path.parent() {
415            let _ = std::fs::remove_dir_all(parent);
416        }
417    }
418
419    fn result_with(ssl: Option<i64>, domain: Option<i64>, issues: &[&str]) -> WatchResult {
420        WatchResult {
421            domain: "x.test".to_string(),
422            ssl_days_remaining: ssl,
423            domain_days_remaining: domain,
424            registrar: None,
425            http_status: Some(200),
426            issues: issues.iter().map(|s| s.to_string()).collect(),
427        }
428    }
429
430    #[test]
431    fn critical_uses_explicit_expiry_bands() {
432        // Registration expiry in the 30..90-day warning band is NOT critical,
433        // even though it surfaces an issue line; < 30 days IS critical (#57).
434        assert!(!result_is_critical(&result_with(
435            None,
436            Some(60),
437            &["Domain expires in 60 days"]
438        )));
439        assert!(result_is_critical(&result_with(
440            None,
441            Some(20),
442            &["Domain expires in 20 days"]
443        )));
444        // SSL critical band and invalid cert.
445        assert!(result_is_critical(&result_with(Some(10), None, &[])));
446        assert!(result_is_critical(&result_with(
447            Some(200),
448            None,
449            &["SSL certificate invalid"]
450        )));
451        // Failed check is critical.
452        assert!(result_is_critical(&result_with(
453            None,
454            None,
455            &["Check failed: connection refused"]
456        )));
457    }
458
459    #[test]
460    fn critical_predicate_is_structured_not_freetext() {
461        // A healthy domain whose issue text merely contains the word "failed"
462        // (or "invalid") must NOT be counted critical — the old predicate
463        // scanned free text and was locale/wording-fragile (issue #57).
464        let r = result_with(
465            Some(200),
466            Some(200),
467            &["Note: a prior validation failed last week"],
468        );
469        assert!(
470            !result_is_critical(&r),
471            "free-text 'failed' must not trip the critical predicate"
472        );
473    }
474
475    #[test]
476    fn test_watch_result_serialization() {
477        let result = WatchResult {
478            domain: "example.com".to_string(),
479            ssl_days_remaining: Some(45),
480            domain_days_remaining: Some(120),
481            registrar: Some("Test Registrar".to_string()),
482            http_status: Some(200),
483            issues: vec![],
484        };
485        let json = serde_json::to_string(&result).unwrap();
486        assert!(json.contains("example.com"));
487        assert!(json.contains("45"));
488    }
489}