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 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 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 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 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 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
165fn 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
177const EXPIRY_CRITICAL_DAYS: i64 = 30;
179const DOMAIN_EXPIRY_WARN_DAYS: i64 = 90;
184const SSL_INVALID_ISSUE: &str = "SSL certificate invalid";
187const CHECK_FAILED_PREFIX: &str = "Check failed:";
188
189fn 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 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
211pub async fn check_watchlist(domains: &[String]) -> WatchReport {
213 use futures::stream::{self, StreamExt};
214
215 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 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()); assert_eq!(wl.domains.len(), 1);
318
319 assert!(wl.add("test.org").unwrap());
320 assert_eq!(wl.domains.len(), 2);
321 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")); 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 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 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 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 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 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 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 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 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 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}