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    /// `unique_tmp_path`) 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        if let Some(parent) = path.parent() {
121            std::fs::create_dir_all(parent).map_err(|e| SeerError::ConfigError(e.to_string()))?;
122            #[cfg(unix)]
123            {
124                use std::os::unix::fs::PermissionsExt;
125                let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700));
126            }
127        }
128        let content =
129            toml::to_string_pretty(self).map_err(|e| SeerError::ConfigError(e.to_string()))?;
130        let tmp_path = unique_tmp_path(path, "toml");
131        std::fs::write(&tmp_path, content).map_err(|e| SeerError::ConfigError(e.to_string()))?;
132        #[cfg(unix)]
133        {
134            use std::os::unix::fs::PermissionsExt;
135            let _ = std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o600));
136        }
137        std::fs::rename(&tmp_path, path).map_err(|e| {
138            let _ = std::fs::remove_file(&tmp_path);
139            SeerError::ConfigError(e.to_string())
140        })?;
141        Ok(())
142    }
143
144    /// Adds a domain to the watchlist. Returns `Ok(true)` if the domain was newly added.
145    pub fn add(&mut self, domain: &str) -> Result<bool> {
146        let domain = crate::validation::normalize_domain(domain)?;
147        if self.domains.contains(&domain) {
148            return Ok(false);
149        }
150        self.domains.push(domain);
151        self.domains.sort();
152        Ok(true)
153    }
154
155    /// Removes a domain from the watchlist. Returns `true` if the domain was present.
156    pub fn remove(&mut self, domain: &str) -> bool {
157        let domain =
158            crate::validation::normalize_domain(domain).unwrap_or_else(|_| domain.to_lowercase());
159        let len_before = self.domains.len();
160        self.domains.retain(|d| d != &domain);
161        self.domains.len() < len_before
162    }
163}
164
165/// Returns a per-call-unique sibling temp path for an atomic save. The PID
166/// alone is not unique enough: same-process concurrent saves are reachable
167/// (e.g. detached TUI writes), and a shared temp path lets one writer
168/// truncate the other's finished bytes before its rename — a torn rename
169/// that publishes a corrupt file. Mirrors `history::unique_tmp_path`.
170fn unique_tmp_path(path: &std::path::Path, ext: &str) -> PathBuf {
171    use std::sync::atomic::{AtomicU64, Ordering};
172    static SAVE_COUNTER: AtomicU64 = AtomicU64::new(0);
173    let seq = SAVE_COUNTER.fetch_add(1, Ordering::Relaxed);
174    path.with_extension(format!("{}.{}.{}.tmp", ext, std::process::id(), seq))
175}
176
177/// SSL or domain-registration expiry within this many days is *critical*.
178const EXPIRY_CRITICAL_DAYS: i64 = 30;
179/// Domain-registration expiry within this many days surfaces an informational
180/// (warning-band) issue even before it becomes critical. SSL uses only the
181/// critical band. Defined as named constants so the issue-push thresholds and
182/// the critical tally are a single source of truth (issue #57).
183const DOMAIN_EXPIRY_WARN_DAYS: i64 = 90;
184/// Exact issue strings this module emits, so the critical predicate can match
185/// them structurally rather than scanning free text for "invalid"/"failed".
186const SSL_INVALID_ISSUE: &str = "SSL certificate invalid";
187const CHECK_FAILED_PREFIX: &str = "Check failed:";
188
189/// Returns true if a checked result is *critical* (vs merely a warning): an SSL
190/// or registration expiry within [`EXPIRY_CRITICAL_DAYS`], an invalid SSL
191/// certificate, or a failed check. Uses the numeric day fields and the exact
192/// issue markers this module emits — not a locale/text-fragile substring scan
193/// for "invalid"/"failed" (issue #57).
194fn result_is_critical(r: &WatchResult) -> bool {
195    let bad_ssl = r
196        .ssl_days_remaining
197        .is_some_and(|d| d < EXPIRY_CRITICAL_DAYS);
198    let bad_domain = r
199        .domain_days_remaining
200        .is_some_and(|d| d < EXPIRY_CRITICAL_DAYS);
201    // Match the exact markers this module emits, not arbitrary free text, so a
202    // benign issue line that happens to contain "failed"/"invalid" can't be
203    // miscounted as critical.
204    let bad_issue = r
205        .issues
206        .iter()
207        .any(|i| i == SSL_INVALID_ISSUE || i.starts_with(CHECK_FAILED_PREFIX));
208    bad_ssl || bad_domain || bad_issue
209}
210
211/// Checks all given domains concurrently and produces a [`WatchReport`].
212pub async fn check_watchlist(domains: &[String]) -> WatchReport {
213    use futures::stream::{self, StreamExt};
214
215    // Each per-domain future owns its `client` (via `Arc`) and `domain`
216    // (owned `String`) so the `buffer_unordered` futures are `Send + 'static`
217    // and the whole `check_watchlist` future can be used from `tokio::spawn`
218    // (e.g. the TUI). Borrowing `&client`/`&String` here makes the closure fail
219    // the higher-ranked `FnOnce` bound `tokio::spawn` requires.
220    let client = std::sync::Arc::new(StatusClient::new());
221
222    let results: Vec<WatchResult> = stream::iter(domains.iter().cloned())
223        .map(|domain| {
224            let client = client.clone();
225            async move {
226                let mut watch_result = WatchResult {
227                    domain: domain.clone(),
228                    ssl_days_remaining: None,
229                    domain_days_remaining: None,
230                    registrar: None,
231                    http_status: None,
232                    issues: vec![],
233                };
234
235                match client.check(&domain).await {
236                    Ok(status) => {
237                        watch_result.http_status = status.http_status;
238
239                        if let Some(ref cert) = status.certificate {
240                            watch_result.ssl_days_remaining = Some(cert.days_until_expiry);
241                            if cert.days_until_expiry < EXPIRY_CRITICAL_DAYS {
242                                watch_result.issues.push(format!(
243                                    "SSL expires in {} days",
244                                    cert.days_until_expiry
245                                ));
246                            }
247                            if !cert.is_valid {
248                                watch_result.issues.push(SSL_INVALID_ISSUE.to_string());
249                            }
250                        }
251
252                        if let Some(ref exp) = status.domain_expiration {
253                            watch_result.domain_days_remaining = Some(exp.days_until_expiry);
254                            watch_result.registrar = exp.registrar.clone();
255                            if exp.days_until_expiry < DOMAIN_EXPIRY_WARN_DAYS {
256                                watch_result.issues.push(format!(
257                                    "Domain expires in {} days",
258                                    exp.days_until_expiry
259                                ));
260                            }
261                        }
262
263                        if let Some(status_code) = status.http_status {
264                            if !(200..300).contains(&status_code) {
265                                watch_result
266                                    .issues
267                                    .push(format!("HTTP status {}", status_code));
268                            }
269                        }
270                    }
271                    Err(e) => {
272                        watch_result
273                            .issues
274                            .push(format!("{} {}", CHECK_FAILED_PREFIX, e));
275                    }
276                }
277
278                watch_result
279            }
280        })
281        .buffer_unordered(10)
282        .collect()
283        .await;
284
285    let total = results.len();
286    // Critical vs warning use explicit, shared bands (see `result_is_critical`
287    // and the EXPIRY_* constants) so the tally lines up with the human-visible
288    // issue lines: a registration expiry in the 30..90-day warning band shows
289    // an issue and counts as a warning, while < 30 days counts as critical.
290    let critical = results.iter().filter(|r| result_is_critical(r)).count();
291    let warnings = results.iter().filter(|r| !r.issues.is_empty()).count();
292
293    WatchReport {
294        checked_at: Utc::now(),
295        results,
296        total,
297        warnings,
298        critical,
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn test_watchlist_default() {
308        let wl = Watchlist::default();
309        assert!(wl.domains.is_empty());
310    }
311
312    #[test]
313    fn test_watchlist_add_remove() {
314        let mut wl = Watchlist::default();
315        assert!(wl.add("example.com").unwrap());
316        assert!(!wl.add("example.com").unwrap()); // duplicate
317        assert_eq!(wl.domains.len(), 1);
318
319        assert!(wl.add("test.org").unwrap());
320        assert_eq!(wl.domains.len(), 2);
321        // Should be sorted
322        assert_eq!(wl.domains[0], "example.com");
323        assert_eq!(wl.domains[1], "test.org");
324
325        assert!(wl.remove("example.com"));
326        assert!(!wl.remove("example.com")); // already removed
327        assert_eq!(wl.domains.len(), 1);
328    }
329
330    #[test]
331    fn test_watchlist_add_normalizes_case() {
332        let mut wl = Watchlist::default();
333        wl.add("EXAMPLE.COM").unwrap();
334        assert_eq!(wl.domains[0], "example.com");
335    }
336
337    #[test]
338    fn test_watchlist_serialization() {
339        let mut wl = Watchlist::default();
340        wl.add("a.com").unwrap();
341        wl.add("b.org").unwrap();
342        let toml_str = toml::to_string_pretty(&wl).unwrap();
343        assert!(toml_str.contains("a.com"));
344        assert!(toml_str.contains("b.org"));
345
346        let parsed: Watchlist = toml::from_str(&toml_str).unwrap();
347        assert_eq!(parsed.domains.len(), 2);
348    }
349
350    /// Creates a unique temporary file path for a load-from-disk test.
351    fn unique_temp_watchlist_path(tag: &str) -> PathBuf {
352        let mut dir = std::env::temp_dir();
353        dir.push(format!(
354            "seer-watchlist-test-{}-{}",
355            tag,
356            std::process::id()
357        ));
358        let _ = std::fs::create_dir_all(&dir);
359        dir.push("watchlist.toml");
360        dir
361    }
362
363    #[test]
364    fn load_from_path_returns_default_and_backs_up_corrupt_file() {
365        let path = unique_temp_watchlist_path("corrupt");
366        let backup = path.with_extension("corrupt");
367
368        let _ = std::fs::remove_file(&path);
369        let _ = std::fs::remove_file(&backup);
370
371        // TOML parsers reject stray garbage on the value side of `=`.
372        std::fs::write(&path, b"domains = not-an-array-\n").expect("seed corrupt watchlist file");
373
374        let loaded = Watchlist::load_from_path(&path);
375        assert!(
376            loaded.domains.is_empty(),
377            "corrupt watchlist must load as empty default"
378        );
379        assert!(
380            !path.exists(),
381            "original corrupt file should have been renamed away"
382        );
383        assert!(
384            backup.exists(),
385            "backup .corrupt file should exist at {}",
386            backup.display()
387        );
388
389        let _ = std::fs::remove_file(&backup);
390        if let Some(parent) = path.parent() {
391            let _ = std::fs::remove_dir_all(parent);
392        }
393    }
394
395    #[test]
396    fn load_from_path_returns_default_when_missing() {
397        let path = unique_temp_watchlist_path("missing");
398        let _ = std::fs::remove_file(&path);
399
400        let loaded = Watchlist::load_from_path(&path);
401        assert!(loaded.domains.is_empty());
402
403        if let Some(parent) = path.parent() {
404            let _ = std::fs::remove_dir_all(parent);
405        }
406    }
407
408    #[test]
409    fn tmp_paths_are_unique_per_call() {
410        // PID-only temp names collide across same-process concurrent saves;
411        // every call must get its own temp path.
412        let target = std::path::Path::new("/some/dir/watchlist.toml");
413        assert_ne!(
414            unique_tmp_path(target, "toml"),
415            unique_tmp_path(target, "toml"),
416            "two saves in one process must not share a temp path"
417        );
418    }
419
420    #[test]
421    fn concurrent_saves_do_not_corrupt_the_file() {
422        // Two same-process writers saving to the same target concurrently:
423        // with a shared (PID-only) temp path one writer truncates the other's
424        // finished bytes and the loser's rename fails (or publishes a torn
425        // file). With per-call temp paths every save succeeds and the last
426        // rename wins with a complete file. Mirrors the history.rs test.
427        let path = unique_temp_watchlist_path("concurrent");
428        let _ = std::fs::remove_file(&path);
429
430        let mut a = Watchlist::default();
431        a.add("a.example").unwrap();
432        let mut b = Watchlist::default();
433        for i in 0..100 {
434            b.add(&format!("b{i}.example")).unwrap();
435        }
436
437        let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
438        let spawn_saver =
439            |wl: Watchlist, path: PathBuf, barrier: std::sync::Arc<std::sync::Barrier>| {
440                std::thread::spawn(move || {
441                    for _ in 0..50 {
442                        barrier.wait();
443                        wl.save_to_path(&path).expect("concurrent save failed");
444                    }
445                })
446            };
447        let ta = spawn_saver(a, path.clone(), barrier.clone());
448        let tb = spawn_saver(b, path.clone(), barrier);
449        ta.join().expect("thread A panicked");
450        tb.join().expect("thread B panicked");
451
452        // Whichever writer won the last rename, the file must be complete.
453        let content = std::fs::read_to_string(&path).expect("saved file exists");
454        toml::from_str::<Watchlist>(&content)
455            .expect("concurrently saved watchlist must parse (no torn rename)");
456
457        if let Some(parent) = path.parent() {
458            let _ = std::fs::remove_dir_all(parent);
459        }
460    }
461
462    fn result_with(ssl: Option<i64>, domain: Option<i64>, issues: &[&str]) -> WatchResult {
463        WatchResult {
464            domain: "x.test".to_string(),
465            ssl_days_remaining: ssl,
466            domain_days_remaining: domain,
467            registrar: None,
468            http_status: Some(200),
469            issues: issues.iter().map(|s| s.to_string()).collect(),
470        }
471    }
472
473    #[test]
474    fn critical_uses_explicit_expiry_bands() {
475        // Registration expiry in the 30..90-day warning band is NOT critical,
476        // even though it surfaces an issue line; < 30 days IS critical (#57).
477        assert!(!result_is_critical(&result_with(
478            None,
479            Some(60),
480            &["Domain expires in 60 days"]
481        )));
482        assert!(result_is_critical(&result_with(
483            None,
484            Some(20),
485            &["Domain expires in 20 days"]
486        )));
487        // SSL critical band and invalid cert.
488        assert!(result_is_critical(&result_with(Some(10), None, &[])));
489        assert!(result_is_critical(&result_with(
490            Some(200),
491            None,
492            &["SSL certificate invalid"]
493        )));
494        // Failed check is critical.
495        assert!(result_is_critical(&result_with(
496            None,
497            None,
498            &["Check failed: connection refused"]
499        )));
500    }
501
502    #[test]
503    fn critical_predicate_is_structured_not_freetext() {
504        // A healthy domain whose issue text merely contains the word "failed"
505        // (or "invalid") must NOT be counted critical — the old predicate
506        // scanned free text and was locale/wording-fragile (issue #57).
507        let r = result_with(
508            Some(200),
509            Some(200),
510            &["Note: a prior validation failed last week"],
511        );
512        assert!(
513            !result_is_critical(&r),
514            "free-text 'failed' must not trip the critical predicate"
515        );
516    }
517
518    #[test]
519    fn test_watch_result_serialization() {
520        let result = WatchResult {
521            domain: "example.com".to_string(),
522            ssl_days_remaining: Some(45),
523            domain_days_remaining: Some(120),
524            registrar: Some("Test Registrar".to_string()),
525            http_status: Some(200),
526            issues: vec![],
527        };
528        let json = serde_json::to_string(&result).unwrap();
529        assert!(json.contains("example.com"));
530        assert!(json.contains("45"));
531    }
532}