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 suffixed with the current PID so two concurrent
100    /// `seer` processes don't write to the same intermediate path and race
101    /// each other's `rename`s.
102    ///
103    /// # Concurrency
104    ///
105    /// As with [`crate::history::LookupHistory::save`], the write is atomic but
106    /// the load → add/remove → save cycle is not cross-process locked: two
107    /// concurrent writers can lose one side's add/remove (last-writer-wins). No
108    /// corruption occurs. A cross-process advisory lock would close the window;
109    /// it is omitted to avoid a new dependency for a low-frequency edge case.
110    pub fn save(&self) -> Result<()> {
111        let path = Self::path()
112            .ok_or_else(|| SeerError::ConfigError("Cannot determine home directory".to_string()))?;
113        if let Some(parent) = path.parent() {
114            std::fs::create_dir_all(parent).map_err(|e| SeerError::ConfigError(e.to_string()))?;
115            #[cfg(unix)]
116            {
117                use std::os::unix::fs::PermissionsExt;
118                let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700));
119            }
120        }
121        let content =
122            toml::to_string_pretty(self).map_err(|e| SeerError::ConfigError(e.to_string()))?;
123        let tmp_path = path.with_extension(format!("toml.{}.tmp", std::process::id()));
124        std::fs::write(&tmp_path, content).map_err(|e| SeerError::ConfigError(e.to_string()))?;
125        #[cfg(unix)]
126        {
127            use std::os::unix::fs::PermissionsExt;
128            let _ = std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o600));
129        }
130        std::fs::rename(&tmp_path, &path).map_err(|e| {
131            let _ = std::fs::remove_file(&tmp_path);
132            SeerError::ConfigError(e.to_string())
133        })?;
134        Ok(())
135    }
136
137    /// Adds a domain to the watchlist. Returns `Ok(true)` if the domain was newly added.
138    pub fn add(&mut self, domain: &str) -> Result<bool> {
139        let domain = crate::validation::normalize_domain(domain)?;
140        if self.domains.contains(&domain) {
141            return Ok(false);
142        }
143        self.domains.push(domain);
144        self.domains.sort();
145        Ok(true)
146    }
147
148    /// Removes a domain from the watchlist. Returns `true` if the domain was present.
149    pub fn remove(&mut self, domain: &str) -> bool {
150        let domain =
151            crate::validation::normalize_domain(domain).unwrap_or_else(|_| domain.to_lowercase());
152        let len_before = self.domains.len();
153        self.domains.retain(|d| d != &domain);
154        self.domains.len() < len_before
155    }
156}
157
158/// SSL or domain-registration expiry within this many days is *critical*.
159const EXPIRY_CRITICAL_DAYS: i64 = 30;
160/// Domain-registration expiry within this many days surfaces an informational
161/// (warning-band) issue even before it becomes critical. SSL uses only the
162/// critical band. Defined as named constants so the issue-push thresholds and
163/// the critical tally are a single source of truth (issue #57).
164const DOMAIN_EXPIRY_WARN_DAYS: i64 = 90;
165/// Exact issue strings this module emits, so the critical predicate can match
166/// them structurally rather than scanning free text for "invalid"/"failed".
167const SSL_INVALID_ISSUE: &str = "SSL certificate invalid";
168const CHECK_FAILED_PREFIX: &str = "Check failed:";
169
170/// Returns true if a checked result is *critical* (vs merely a warning): an SSL
171/// or registration expiry within [`EXPIRY_CRITICAL_DAYS`], an invalid SSL
172/// certificate, or a failed check. Uses the numeric day fields and the exact
173/// issue markers this module emits — not a locale/text-fragile substring scan
174/// for "invalid"/"failed" (issue #57).
175fn result_is_critical(r: &WatchResult) -> bool {
176    let bad_ssl = r
177        .ssl_days_remaining
178        .is_some_and(|d| d < EXPIRY_CRITICAL_DAYS);
179    let bad_domain = r
180        .domain_days_remaining
181        .is_some_and(|d| d < EXPIRY_CRITICAL_DAYS);
182    // Match the exact markers this module emits, not arbitrary free text, so a
183    // benign issue line that happens to contain "failed"/"invalid" can't be
184    // miscounted as critical.
185    let bad_issue = r
186        .issues
187        .iter()
188        .any(|i| i == SSL_INVALID_ISSUE || i.starts_with(CHECK_FAILED_PREFIX));
189    bad_ssl || bad_domain || bad_issue
190}
191
192/// Checks all given domains concurrently and produces a [`WatchReport`].
193pub async fn check_watchlist(domains: &[String]) -> WatchReport {
194    use futures::stream::{self, StreamExt};
195
196    // Each per-domain future owns its `client` (via `Arc`) and `domain`
197    // (owned `String`) so the `buffer_unordered` futures are `Send + 'static`
198    // and the whole `check_watchlist` future can be used from `tokio::spawn`
199    // (e.g. the TUI). Borrowing `&client`/`&String` here makes the closure fail
200    // the higher-ranked `FnOnce` bound `tokio::spawn` requires.
201    let client = std::sync::Arc::new(StatusClient::new());
202
203    let results: Vec<WatchResult> = stream::iter(domains.iter().cloned())
204        .map(|domain| {
205            let client = client.clone();
206            async move {
207                let mut watch_result = WatchResult {
208                    domain: domain.clone(),
209                    ssl_days_remaining: None,
210                    domain_days_remaining: None,
211                    registrar: None,
212                    http_status: None,
213                    issues: vec![],
214                };
215
216                match client.check(&domain).await {
217                    Ok(status) => {
218                        watch_result.http_status = status.http_status;
219
220                        if let Some(ref cert) = status.certificate {
221                            watch_result.ssl_days_remaining = Some(cert.days_until_expiry);
222                            if cert.days_until_expiry < EXPIRY_CRITICAL_DAYS {
223                                watch_result.issues.push(format!(
224                                    "SSL expires in {} days",
225                                    cert.days_until_expiry
226                                ));
227                            }
228                            if !cert.is_valid {
229                                watch_result.issues.push(SSL_INVALID_ISSUE.to_string());
230                            }
231                        }
232
233                        if let Some(ref exp) = status.domain_expiration {
234                            watch_result.domain_days_remaining = Some(exp.days_until_expiry);
235                            watch_result.registrar = exp.registrar.clone();
236                            if exp.days_until_expiry < DOMAIN_EXPIRY_WARN_DAYS {
237                                watch_result.issues.push(format!(
238                                    "Domain expires in {} days",
239                                    exp.days_until_expiry
240                                ));
241                            }
242                        }
243
244                        if let Some(status_code) = status.http_status {
245                            if !(200..300).contains(&status_code) {
246                                watch_result
247                                    .issues
248                                    .push(format!("HTTP status {}", status_code));
249                            }
250                        }
251                    }
252                    Err(e) => {
253                        watch_result
254                            .issues
255                            .push(format!("{} {}", CHECK_FAILED_PREFIX, e));
256                    }
257                }
258
259                watch_result
260            }
261        })
262        .buffer_unordered(10)
263        .collect()
264        .await;
265
266    let total = results.len();
267    // Critical vs warning use explicit, shared bands (see `result_is_critical`
268    // and the EXPIRY_* constants) so the tally lines up with the human-visible
269    // issue lines: a registration expiry in the 30..90-day warning band shows
270    // an issue and counts as a warning, while < 30 days counts as critical.
271    let critical = results.iter().filter(|r| result_is_critical(r)).count();
272    let warnings = results.iter().filter(|r| !r.issues.is_empty()).count();
273
274    WatchReport {
275        checked_at: Utc::now(),
276        results,
277        total,
278        warnings,
279        critical,
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    #[test]
288    fn test_watchlist_default() {
289        let wl = Watchlist::default();
290        assert!(wl.domains.is_empty());
291    }
292
293    #[test]
294    fn test_watchlist_add_remove() {
295        let mut wl = Watchlist::default();
296        assert!(wl.add("example.com").unwrap());
297        assert!(!wl.add("example.com").unwrap()); // duplicate
298        assert_eq!(wl.domains.len(), 1);
299
300        assert!(wl.add("test.org").unwrap());
301        assert_eq!(wl.domains.len(), 2);
302        // Should be sorted
303        assert_eq!(wl.domains[0], "example.com");
304        assert_eq!(wl.domains[1], "test.org");
305
306        assert!(wl.remove("example.com"));
307        assert!(!wl.remove("example.com")); // already removed
308        assert_eq!(wl.domains.len(), 1);
309    }
310
311    #[test]
312    fn test_watchlist_add_normalizes_case() {
313        let mut wl = Watchlist::default();
314        wl.add("EXAMPLE.COM").unwrap();
315        assert_eq!(wl.domains[0], "example.com");
316    }
317
318    #[test]
319    fn test_watchlist_serialization() {
320        let mut wl = Watchlist::default();
321        wl.add("a.com").unwrap();
322        wl.add("b.org").unwrap();
323        let toml_str = toml::to_string_pretty(&wl).unwrap();
324        assert!(toml_str.contains("a.com"));
325        assert!(toml_str.contains("b.org"));
326
327        let parsed: Watchlist = toml::from_str(&toml_str).unwrap();
328        assert_eq!(parsed.domains.len(), 2);
329    }
330
331    /// Creates a unique temporary file path for a load-from-disk test.
332    fn unique_temp_watchlist_path(tag: &str) -> PathBuf {
333        let mut dir = std::env::temp_dir();
334        dir.push(format!(
335            "seer-watchlist-test-{}-{}",
336            tag,
337            std::process::id()
338        ));
339        let _ = std::fs::create_dir_all(&dir);
340        dir.push("watchlist.toml");
341        dir
342    }
343
344    #[test]
345    fn load_from_path_returns_default_and_backs_up_corrupt_file() {
346        let path = unique_temp_watchlist_path("corrupt");
347        let backup = path.with_extension("corrupt");
348
349        let _ = std::fs::remove_file(&path);
350        let _ = std::fs::remove_file(&backup);
351
352        // TOML parsers reject stray garbage on the value side of `=`.
353        std::fs::write(&path, b"domains = not-an-array-\n").expect("seed corrupt watchlist file");
354
355        let loaded = Watchlist::load_from_path(&path);
356        assert!(
357            loaded.domains.is_empty(),
358            "corrupt watchlist must load as empty default"
359        );
360        assert!(
361            !path.exists(),
362            "original corrupt file should have been renamed away"
363        );
364        assert!(
365            backup.exists(),
366            "backup .corrupt file should exist at {}",
367            backup.display()
368        );
369
370        let _ = std::fs::remove_file(&backup);
371        if let Some(parent) = path.parent() {
372            let _ = std::fs::remove_dir_all(parent);
373        }
374    }
375
376    #[test]
377    fn load_from_path_returns_default_when_missing() {
378        let path = unique_temp_watchlist_path("missing");
379        let _ = std::fs::remove_file(&path);
380
381        let loaded = Watchlist::load_from_path(&path);
382        assert!(loaded.domains.is_empty());
383
384        if let Some(parent) = path.parent() {
385            let _ = std::fs::remove_dir_all(parent);
386        }
387    }
388
389    fn result_with(ssl: Option<i64>, domain: Option<i64>, issues: &[&str]) -> WatchResult {
390        WatchResult {
391            domain: "x.test".to_string(),
392            ssl_days_remaining: ssl,
393            domain_days_remaining: domain,
394            registrar: None,
395            http_status: Some(200),
396            issues: issues.iter().map(|s| s.to_string()).collect(),
397        }
398    }
399
400    #[test]
401    fn critical_uses_explicit_expiry_bands() {
402        // Registration expiry in the 30..90-day warning band is NOT critical,
403        // even though it surfaces an issue line; < 30 days IS critical (#57).
404        assert!(!result_is_critical(&result_with(
405            None,
406            Some(60),
407            &["Domain expires in 60 days"]
408        )));
409        assert!(result_is_critical(&result_with(
410            None,
411            Some(20),
412            &["Domain expires in 20 days"]
413        )));
414        // SSL critical band and invalid cert.
415        assert!(result_is_critical(&result_with(Some(10), None, &[])));
416        assert!(result_is_critical(&result_with(
417            Some(200),
418            None,
419            &["SSL certificate invalid"]
420        )));
421        // Failed check is critical.
422        assert!(result_is_critical(&result_with(
423            None,
424            None,
425            &["Check failed: connection refused"]
426        )));
427    }
428
429    #[test]
430    fn critical_predicate_is_structured_not_freetext() {
431        // A healthy domain whose issue text merely contains the word "failed"
432        // (or "invalid") must NOT be counted critical — the old predicate
433        // scanned free text and was locale/wording-fragile (issue #57).
434        let r = result_with(
435            Some(200),
436            Some(200),
437            &["Note: a prior validation failed last week"],
438        );
439        assert!(
440            !result_is_critical(&r),
441            "free-text 'failed' must not trip the critical predicate"
442        );
443    }
444
445    #[test]
446    fn test_watch_result_serialization() {
447        let result = WatchResult {
448            domain: "example.com".to_string(),
449            ssl_days_remaining: Some(45),
450            domain_days_remaining: Some(120),
451            registrar: Some("Test Registrar".to_string()),
452            http_status: Some(200),
453            issues: vec![],
454        };
455        let json = serde_json::to_string(&result).unwrap();
456        assert!(json.contains("example.com"));
457        assert!(json.contains("45"));
458    }
459}