Skip to main content

rprobe/
storage.rs

1// File: storage.rs
2// SPDX-License-Identifier: MIT OR Apache-2.0
3//
4// Copyright (c) 2023-2025
5// - Volker Schwaberow <volker@schwaberow.de>
6
7use anyhow::{Context, Result};
8use chrono::{DateTime, Utc};
9use dirs::data_local_dir;
10use serde::{de::DeserializeOwned, Deserialize, Serialize};
11use sled::{Config, Db};
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14
15use crate::content_analyzer::{ContentFinding, FindingSeverity};
16use crate::desync_scanner::DesyncResult;
17
18/// Current on-disk value encoding: bincode 2 (`bincode::serde` + standard config).
19pub const SCHEMA_VERSION: u32 = 2;
20const SCHEMA_VERSION_KEY: &[u8] = b"schema_version";
21const META_TREE: &[u8] = b"meta";
22
23fn bincode_config() -> impl bincode::config::Config {
24    bincode::config::standard()
25}
26
27fn encode_value<T: Serialize>(value: &T) -> Result<Vec<u8>> {
28    bincode::serde::encode_to_vec(value, bincode_config()).context("Failed to serialize value")
29}
30
31fn decode_value_v2<T: DeserializeOwned>(bytes: &[u8]) -> Result<T> {
32    let (value, _) = bincode::serde::decode_from_slice(bytes, bincode_config())
33        .context("Failed to deserialize value (schema v2)")?;
34    Ok(value)
35}
36
37fn decode_value_v1<T: DeserializeOwned>(bytes: &[u8]) -> Result<T> {
38    bincode1::deserialize(bytes).context("Failed to deserialize value (schema v1)")
39}
40
41fn decode_value<T: DeserializeOwned>(bytes: &[u8]) -> Result<T> {
42    decode_value_v2(bytes).or_else(|_| decode_value_v1(bytes))
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct ScanRecord {
47    pub id: String,
48    pub timestamp: DateTime<Utc>,
49    pub url: String,
50    pub status: String,
51    pub detections: Vec<String>,
52    pub content_findings: Vec<ContentFinding>,
53    pub tls_info: HashMap<String, String>,
54    pub response_time_ms: Option<u64>,
55    pub response_headers: HashMap<String, String>,
56    pub content_length: Option<u64>,
57    pub desync_results: Vec<DesyncResult>,
58    pub screenshot_path: Option<String>,
59    pub robots_txt_content: Option<String>,
60    pub scan_config: ScanConfig,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct ScanConfig {
65    pub timeout: u64,
66    pub http: bool,
67    pub https: bool,
68    pub detect_all: bool,
69    pub content_analysis: bool,
70    pub tls_analysis: bool,
71    pub comprehensive_tls: bool,
72    pub screenshot: bool,
73    pub download_robots: bool,
74    pub desync: bool,
75    pub plugin_name: Option<String>,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct ScanSession {
80    pub id: String,
81    pub timestamp: DateTime<Utc>,
82    pub total_urls: usize,
83    pub successful_scans: usize,
84    pub failed_scans: usize,
85    pub duration_ms: u64,
86    pub config: ScanConfig,
87}
88
89#[derive(Debug, Clone)]
90pub struct HistoryQuery {
91    pub url_pattern: Option<String>,
92    pub start_date: Option<DateTime<Utc>>,
93    pub end_date: Option<DateTime<Utc>>,
94    pub min_severity: Option<FindingSeverity>,
95    pub has_detections: Option<bool>,
96    pub has_tls_issues: Option<bool>,
97    pub has_desync_findings: Option<bool>,
98    pub status_codes: Option<Vec<String>>,
99    pub limit: Option<usize>,
100}
101
102impl Default for HistoryQuery {
103    fn default() -> Self {
104        Self {
105            url_pattern: None,
106            start_date: None,
107            end_date: None,
108            min_severity: None,
109            has_detections: None,
110            has_tls_issues: None,
111            has_desync_findings: None,
112            status_codes: None,
113            limit: Some(100),
114        }
115    }
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct ComparisonResult {
120    pub url: String,
121    pub old_record: Option<ScanRecord>,
122    pub new_record: Option<ScanRecord>,
123    pub changes: Vec<String>,
124}
125
126
127#[derive(Debug, Clone)]
128pub struct MigrationReport {
129    pub from_version: u32,
130    pub to_version: u32,
131    pub scans_migrated: u64,
132    pub sessions_migrated: u64,
133    pub backup_path: Option<PathBuf>,
134    pub dry_run: bool,
135    pub skipped: bool,
136}
137
138pub struct HistoryDatabase {
139    db: Db,
140    db_path: PathBuf,
141    scans_tree: sled::Tree,
142    sessions_tree: sled::Tree,
143    url_index_tree: sled::Tree,
144    meta_tree: sled::Tree,
145}
146
147impl HistoryDatabase {
148    pub fn resolve_path(data_dir: Option<PathBuf>) -> PathBuf {
149        match data_dir {
150            Some(dir) => dir.join("rprobe_history"),
151            None => data_local_dir()
152                .or_else(|| Some(PathBuf::from(".")))
153                .unwrap()
154                .join("rprobe")
155                .join("history"),
156        }
157    }
158
159    pub fn new(data_dir: Option<PathBuf>) -> Result<Self> {
160        Self::open_at(Self::resolve_path(data_dir), true)
161    }
162
163    pub fn open_at(db_path: PathBuf, auto_migrate: bool) -> Result<Self> {
164        std::fs::create_dir_all(&db_path).context("Failed to create database directory")?;
165
166        let db = Config::default()
167            .path(&db_path)
168            .compression_factor(9)
169            .open()
170            .context("Failed to open database")?;
171
172        let scans_tree = db
173            .open_tree(b"scans")
174            .context("Failed to open scans tree")?;
175        let sessions_tree = db
176            .open_tree(b"sessions")
177            .context("Failed to open sessions tree")?;
178        let url_index_tree = db
179            .open_tree(b"url_index")
180            .context("Failed to open URL index tree")?;
181        let meta_tree = db
182            .open_tree(META_TREE)
183            .context("Failed to open meta tree")?;
184
185        let mut this = Self {
186            db,
187            db_path,
188            scans_tree,
189            sessions_tree,
190            url_index_tree,
191            meta_tree,
192        };
193
194        if auto_migrate {
195            this.ensure_current_schema(false)?;
196        }
197
198        Ok(this)
199    }
200
201    pub fn schema_version(&self) -> Result<u32> {
202        match self.meta_tree.get(SCHEMA_VERSION_KEY)? {
203            Some(bytes) if bytes.len() == 4 => {
204                let mut arr = [0u8; 4];
205                arr.copy_from_slice(&bytes);
206                Ok(u32::from_le_bytes(arr))
207            }
208            Some(_) => anyhow::bail!("Corrupt schema_version metadata"),
209            None => {
210                // Legacy DBs (pre-0.10) have no marker. Empty DBs start at current.
211                if self.scans_tree.is_empty() && self.sessions_tree.is_empty() {
212                    Ok(SCHEMA_VERSION)
213                } else {
214                    Ok(1)
215                }
216            }
217        }
218    }
219
220    fn write_schema_version(&self, version: u32) -> Result<()> {
221        self.meta_tree
222            .insert(SCHEMA_VERSION_KEY, &version.to_le_bytes())
223            .context("Failed to write schema_version")?;
224        self.db.flush().context("Failed to flush after schema_version write")?;
225        Ok(())
226    }
227
228    pub fn ensure_current_schema(&mut self, dry_run: bool) -> Result<MigrationReport> {
229        let current = self.schema_version()?;
230        if current == SCHEMA_VERSION {
231            if current != SCHEMA_VERSION {
232                // unreachable
233            }
234            // Fresh empty DBs without meta still need the marker written.
235            if self.meta_tree.get(SCHEMA_VERSION_KEY)?.is_none() {
236                if !dry_run {
237                    self.write_schema_version(SCHEMA_VERSION)?;
238                }
239            }
240            return Ok(MigrationReport {
241                from_version: current,
242                to_version: SCHEMA_VERSION,
243                scans_migrated: 0,
244                sessions_migrated: 0,
245                backup_path: None,
246                dry_run,
247                skipped: true,
248            });
249        }
250
251        if current > SCHEMA_VERSION {
252            anyhow::bail!(
253                "Database schema version {} is newer than supported version {}",
254                current,
255                SCHEMA_VERSION
256            );
257        }
258
259        self.migrate_v1_to_v2(dry_run)
260    }
261
262    pub fn migrate_v1_to_v2(&mut self, dry_run: bool) -> Result<MigrationReport> {
263        let from_version = self.schema_version()?;
264        if from_version >= SCHEMA_VERSION {
265            return Ok(MigrationReport {
266                from_version,
267                to_version: SCHEMA_VERSION,
268                scans_migrated: 0,
269                sessions_migrated: 0,
270                backup_path: None,
271                dry_run,
272                skipped: true,
273            });
274        }
275
276        let backup_path = if dry_run {
277            None
278        } else {
279            let stamp = Utc::now().format("%Y%m%d%H%M%S");
280            let path = self
281                .db_path
282                .parent()
283                .unwrap_or_else(|| Path::new("."))
284                .join(format!("rprobe_history.pre-v2-backup-{}", stamp));
285            self.backup_database(path.to_str().unwrap())?;
286            Some(path)
287        };
288
289        let mut scans_migrated = 0u64;
290        let mut sessions_migrated = 0u64;
291
292        for result in self.scans_tree.iter() {
293            let (key, value) = result.context("Failed to iterate scans during migration")?;
294            let record: ScanRecord = decode_value_v1(&value)
295                .with_context(|| {
296                    format!(
297                        "Failed to decode legacy scan {}",
298                        String::from_utf8_lossy(&key)
299                    )
300                })?;
301            if !dry_run {
302                let encoded = encode_value(&record)?;
303                self.scans_tree
304                    .insert(key, encoded)
305                    .context("Failed to rewrite migrated scan")?;
306            }
307            scans_migrated += 1;
308        }
309
310        for result in self.sessions_tree.iter() {
311            let (key, value) = result.context("Failed to iterate sessions during migration")?;
312            let session: ScanSession = decode_value_v1(&value)
313                .with_context(|| {
314                    format!(
315                        "Failed to decode legacy session {}",
316                        String::from_utf8_lossy(&key)
317                    )
318                })?;
319            if !dry_run {
320                let encoded = encode_value(&session)?;
321                self.sessions_tree
322                    .insert(key, encoded)
323                    .context("Failed to rewrite migrated session")?;
324            }
325            sessions_migrated += 1;
326        }
327
328        if !dry_run {
329            self.write_schema_version(SCHEMA_VERSION)?;
330            self.db.flush().context("Failed to flush after migration")?;
331        }
332
333        Ok(MigrationReport {
334            from_version,
335            to_version: SCHEMA_VERSION,
336            scans_migrated,
337            sessions_migrated,
338            backup_path,
339            dry_run,
340            skipped: false,
341        })
342    }
343
344    pub fn store_scan(&self, record: &ScanRecord) -> Result<()> {
345        let key = format!("{}_{}", record.timestamp.timestamp_millis(), record.id);
346        let value = encode_value(record).context("Failed to serialize scan record")?;
347
348        self.scans_tree
349            .insert(key.as_bytes(), value)
350            .context("Failed to store scan record")?;
351
352        let url_key = format!(
353            "{}_{}_{}",
354            record.url,
355            record.timestamp.timestamp_millis(),
356            record.id
357        );
358        self.url_index_tree
359            .insert(url_key.as_bytes(), key.as_bytes())
360            .context("Failed to update URL index")?;
361
362        self.db.flush().context("Failed to flush database")?;
363        Ok(())
364    }
365
366    pub fn store_scans_batch(&self, records: &[ScanRecord]) -> Result<()> {
367        let mut scan_batch = sled::Batch::default();
368        let mut url_index_batch = sled::Batch::default();
369
370        for record in records {
371            let key = format!("{}_{}", record.timestamp.timestamp_millis(), record.id);
372            let value = encode_value(record).context("Failed to serialize scan record")?;
373
374            scan_batch.insert(key.as_bytes(), value);
375
376            let url_key = format!(
377                "{}_{}_{}",
378                record.url,
379                record.timestamp.timestamp_millis(),
380                record.id
381            );
382            url_index_batch.insert(url_key.as_bytes(), key.as_bytes());
383        }
384
385        self.scans_tree
386            .apply_batch(scan_batch)
387            .context("Failed to store scan batch")?;
388
389        self.url_index_tree
390            .apply_batch(url_index_batch)
391            .context("Failed to update URL index batch")?;
392
393        self.db.flush().context("Failed to flush database")?;
394        Ok(())
395    }
396
397    pub fn store_session(&self, session: &ScanSession) -> Result<()> {
398        let key = format!("{}_{}", session.timestamp.timestamp_millis(), session.id);
399        let value = encode_value(session).context("Failed to serialize scan session")?;
400
401        self.sessions_tree
402            .insert(key.as_bytes(), value)
403            .context("Failed to store scan session")?;
404
405        self.db.flush().context("Failed to flush database")?;
406        Ok(())
407    }
408
409    pub fn query_scans(&self, query: &HistoryQuery) -> Result<Vec<ScanRecord>> {
410        let mut results = Vec::new();
411
412        for result in self.scans_tree.iter() {
413            let (_, value) = result.context("Failed to iterate over scans")?;
414            let record: ScanRecord =
415                decode_value(&value).context("Failed to deserialize scan record")?;
416
417            if self.matches_query(&record, query) {
418                results.push(record);
419            }
420
421            if let Some(limit) = query.limit {
422                if results.len() >= limit {
423                    break;
424                }
425            }
426        }
427
428        results.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
429        Ok(results)
430    }
431
432    pub fn get_url_history(&self, url: &str, limit: Option<usize>) -> Result<Vec<ScanRecord>> {
433        let mut results = Vec::new();
434        let url_prefix = format!("{}_", url);
435
436        for result in self.url_index_tree.scan_prefix(url_prefix.as_bytes()) {
437            let (_, scan_key) = result.context("Failed to scan URL index")?;
438
439            if let Some(scan_data) = self
440                .scans_tree
441                .get(scan_key)
442                .context("Failed to get scan from index")?
443            {
444                let record: ScanRecord = decode_value(&scan_data)
445                    .context("Failed to deserialize indexed scan")?;
446                results.push(record);
447            }
448
449            if let Some(limit) = limit {
450                if results.len() >= limit {
451                    break;
452                }
453            }
454        }
455
456        results.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
457        Ok(results)
458    }
459
460    pub fn compare_scans(
461        &self,
462        url: &str,
463        old_timestamp: DateTime<Utc>,
464        new_timestamp: DateTime<Utc>,
465    ) -> Result<ComparisonResult> {
466        let old_record = self.find_scan_by_url_and_time(url, old_timestamp)?;
467        let new_record = self.find_scan_by_url_and_time(url, new_timestamp)?;
468
469        let changes = self.calculate_changes(&old_record, &new_record);
470
471        Ok(ComparisonResult {
472            url: url.to_string(),
473            old_record,
474            new_record,
475            changes,
476        })
477    }
478
479    pub fn get_database_stats(&self) -> Result<HashMap<String, u64>> {
480        let mut stats = HashMap::new();
481
482        stats.insert("scans_count".to_string(), self.scans_tree.len() as u64);
483        stats.insert(
484            "sessions_count".to_string(),
485            self.sessions_tree.len() as u64,
486        );
487        stats.insert(
488            "url_index_entries".to_string(),
489            self.url_index_tree.len() as u64,
490        );
491
492        let size_on_disk = self
493            .db
494            .size_on_disk()
495            .context("Failed to get database size")?;
496        stats.insert("size_bytes".to_string(), size_on_disk);
497
498        Ok(stats)
499    }
500
501    pub fn clean_old_data(&self, before: DateTime<Utc>) -> Result<u64> {
502        let mut deleted_count = 0;
503        let cutoff_timestamp = before.timestamp_millis();
504
505        let mut keys_to_delete = Vec::new();
506
507        for result in self.scans_tree.iter() {
508            let (key, value) = result.context("Failed to iterate during cleanup")?;
509            let record: ScanRecord =
510                decode_value(&value).context("Failed to deserialize for cleanup")?;
511
512            if record.timestamp.timestamp_millis() < cutoff_timestamp {
513                keys_to_delete.push(key.to_vec());
514            }
515        }
516
517        for key in keys_to_delete {
518            self.scans_tree
519                .remove(&key)
520                .context("Failed to remove old scan")?;
521            deleted_count += 1;
522        }
523
524        let mut session_keys_to_delete = Vec::new();
525        for result in self.sessions_tree.iter() {
526            let (key, value) = result.context("Failed to iterate sessions during cleanup")?;
527            let session: ScanSession = decode_value(&value)
528                .context("Failed to deserialize session for cleanup")?;
529
530            if session.timestamp.timestamp_millis() < cutoff_timestamp {
531                session_keys_to_delete.push(key.to_vec());
532            }
533        }
534
535        for key in session_keys_to_delete {
536            self.sessions_tree
537                .remove(&key)
538                .context("Failed to remove old session")?;
539        }
540
541        self.rebuild_url_index()?;
542        self.db.flush().context("Failed to flush after cleanup")?;
543
544        Ok(deleted_count)
545    }
546
547    pub fn compact_database(&self) -> Result<()> {
548        let stats_before = self.get_database_stats()?;
549
550        self.scans_tree
551            .flush()
552            .context("Failed to flush scans tree")?;
553        self.sessions_tree
554            .flush()
555            .context("Failed to flush sessions tree")?;
556        self.url_index_tree
557            .flush()
558            .context("Failed to flush URL index tree")?;
559
560        let stats_after = self.get_database_stats()?;
561
562        let size_reduction = stats_before.get("size_bytes").unwrap_or(&0)
563            - stats_after.get("size_bytes").unwrap_or(&0);
564        log::info!(
565            "Database compacted, reduced size by {} bytes",
566            size_reduction
567        );
568
569        Ok(())
570    }
571
572    pub fn backup_database(&self, backup_path: &str) -> Result<()> {
573        let backup_dir = std::path::Path::new(backup_path);
574        std::fs::create_dir_all(backup_dir).context("Failed to create backup directory")?;
575
576        let backup_db = Config::default()
577            .path(backup_dir)
578            .open()
579            .context("Failed to open backup database")?;
580
581        let backup_scans = backup_db
582            .open_tree(b"scans")
583            .context("Failed to open backup scans tree")?;
584        let backup_sessions = backup_db
585            .open_tree(b"sessions")
586            .context("Failed to open backup sessions tree")?;
587        let backup_url_index = backup_db
588            .open_tree(b"url_index")
589            .context("Failed to open backup URL index tree")?;
590
591        for result in self.scans_tree.iter() {
592            let (key, value) = result.context("Failed to iterate scans for backup")?;
593            backup_scans
594                .insert(key, value)
595                .context("Failed to backup scan")?;
596        }
597
598        for result in self.sessions_tree.iter() {
599            let (key, value) = result.context("Failed to iterate sessions for backup")?;
600            backup_sessions
601                .insert(key, value)
602                .context("Failed to backup session")?;
603        }
604
605        for result in self.url_index_tree.iter() {
606            let (key, value) = result.context("Failed to iterate URL index for backup")?;
607            backup_url_index
608                .insert(key, value)
609                .context("Failed to backup URL index")?;
610        }
611
612        backup_db
613            .flush()
614            .context("Failed to flush backup database")?;
615        Ok(())
616    }
617
618    pub fn verify_database_integrity(&self) -> Result<Vec<String>> {
619        let mut issues = Vec::new();
620
621        let scans_count = self.scans_tree.len();
622        let url_index_count = self.url_index_tree.len();
623
624        if scans_count != url_index_count {
625            issues.push(format!(
626                "Index mismatch: {} scans vs {} URL index entries",
627                scans_count, url_index_count
628            ));
629        }
630
631        for result in self.scans_tree.iter() {
632            let (key, value) = result.context("Failed to iterate scans for integrity check")?;
633
634            if decode_value::<ScanRecord>(&value).is_err() {
635                issues.push(format!(
636                    "Corrupted scan record with key: {}",
637                    String::from_utf8_lossy(&key)
638                ));
639            }
640        }
641
642        for result in self.sessions_tree.iter() {
643            let (key, value) = result.context("Failed to iterate sessions for integrity check")?;
644
645            if decode_value::<ScanSession>(&value).is_err() {
646                issues.push(format!(
647                    "Corrupted session record with key: {}",
648                    String::from_utf8_lossy(&key)
649                ));
650            }
651        }
652
653        Ok(issues)
654    }
655
656    fn matches_query(&self, record: &ScanRecord, query: &HistoryQuery) -> bool {
657        if let Some(ref pattern) = query.url_pattern {
658            if !record.url.contains(pattern) {
659                return false;
660            }
661        }
662
663        if let Some(start) = query.start_date {
664            if record.timestamp < start {
665                return false;
666            }
667        }
668
669        if let Some(end) = query.end_date {
670            if record.timestamp > end {
671                return false;
672            }
673        }
674
675        if let Some(ref status_codes) = query.status_codes {
676            if !status_codes.contains(&record.status) {
677                return false;
678            }
679        }
680
681        if let Some(has_detections) = query.has_detections {
682            let has_any = !record.detections.is_empty();
683            if has_detections != has_any {
684                return false;
685            }
686        }
687
688        if let Some(has_tls) = query.has_tls_issues {
689            let has_issues =
690                record.tls_info.contains_key("warnings") || record.tls_info.contains_key("errors");
691            if has_tls != has_issues {
692                return false;
693            }
694        }
695
696        if let Some(has_desync) = query.has_desync_findings {
697            let has_findings = !record.desync_results.is_empty();
698            if has_desync != has_findings {
699                return false;
700            }
701        }
702
703        if let Some(min_severity) = &query.min_severity {
704            let has_min_severity = record
705                .content_findings
706                .iter()
707                .any(|finding| finding.severity >= *min_severity);
708            if !has_min_severity {
709                return false;
710            }
711        }
712
713        true
714    }
715
716    fn find_scan_by_url_and_time(
717        &self,
718        url: &str,
719        timestamp: DateTime<Utc>,
720    ) -> Result<Option<ScanRecord>> {
721        let timestamp_ms = timestamp.timestamp_millis();
722        let url_prefix = format!("{}_", url);
723
724        for result in self.url_index_tree.scan_prefix(url_prefix.as_bytes()) {
725            let (_, scan_key) = result.context("Failed to scan for specific timestamp")?;
726
727            if let Some(scan_data) = self
728                .scans_tree
729                .get(scan_key)
730                .context("Failed to get scan by timestamp")?
731            {
732                let record: ScanRecord = decode_value(&scan_data)
733                    .context("Failed to deserialize timestamped scan")?;
734
735                let diff = (record.timestamp.timestamp_millis() - timestamp_ms).abs();
736                if diff < 60_000 {
737                    return Ok(Some(record));
738                }
739            }
740        }
741
742        Ok(None)
743    }
744
745    fn calculate_changes(&self, old: &Option<ScanRecord>, new: &Option<ScanRecord>) -> Vec<String> {
746        let mut changes = Vec::new();
747
748        match (old, new) {
749            (None, Some(_)) => changes.push("New scan result".to_string()),
750            (Some(_), None) => changes.push("Scan result removed".to_string()),
751            (Some(old_rec), Some(new_rec)) => {
752                if old_rec.status != new_rec.status {
753                    changes.push(format!(
754                        "Status changed: {} -> {}",
755                        old_rec.status, new_rec.status
756                    ));
757                }
758
759                let old_detections: std::collections::HashSet<_> =
760                    old_rec.detections.iter().collect();
761                let new_detections: std::collections::HashSet<_> =
762                    new_rec.detections.iter().collect();
763
764                for detection in new_detections.difference(&old_detections) {
765                    changes.push(format!("New detection: {}", detection));
766                }
767
768                for detection in old_detections.difference(&new_detections) {
769                    changes.push(format!("Detection removed: {}", detection));
770                }
771
772                let old_critical_findings = old_rec
773                    .content_findings
774                    .iter()
775                    .filter(|f| f.severity == FindingSeverity::Critical)
776                    .count();
777                let new_critical_findings = new_rec
778                    .content_findings
779                    .iter()
780                    .filter(|f| f.severity == FindingSeverity::Critical)
781                    .count();
782
783                if old_critical_findings != new_critical_findings {
784                    changes.push(format!(
785                        "Critical findings changed: {} -> {}",
786                        old_critical_findings, new_critical_findings
787                    ));
788                }
789
790                let old_tls_issues = old_rec.tls_info.contains_key("warnings")
791                    || old_rec.tls_info.contains_key("errors");
792                let new_tls_issues = new_rec.tls_info.contains_key("warnings")
793                    || new_rec.tls_info.contains_key("errors");
794
795                if old_tls_issues != new_tls_issues {
796                    if new_tls_issues && !old_tls_issues {
797                        changes.push("New TLS issues detected".to_string());
798                    } else if !new_tls_issues && old_tls_issues {
799                        changes.push("TLS issues resolved".to_string());
800                    }
801                }
802
803                if old_rec.desync_results.len() != new_rec.desync_results.len() {
804                    changes.push(format!(
805                        "Desync results changed: {} -> {} findings",
806                        old_rec.desync_results.len(),
807                        new_rec.desync_results.len()
808                    ));
809                }
810
811                if old_rec.response_time_ms != new_rec.response_time_ms {
812                    let old_time = old_rec.response_time_ms.unwrap_or(0);
813                    let new_time = new_rec.response_time_ms.unwrap_or(0);
814                    let diff = (new_time as i64 - old_time as i64).abs();
815                    if diff > 100 {
816                        changes.push(format!(
817                            "Significant response time change: {}ms -> {}ms",
818                            old_time, new_time
819                        ));
820                    }
821                }
822
823                let old_screenshot = old_rec.screenshot_path.is_some();
824                let new_screenshot = new_rec.screenshot_path.is_some();
825                if old_screenshot != new_screenshot {
826                    if new_screenshot {
827                        changes.push("Screenshot now available".to_string());
828                    } else {
829                        changes.push("Screenshot no longer available".to_string());
830                    }
831                }
832            }
833            (None, None) => {}
834        }
835
836        changes
837    }
838
839    fn rebuild_url_index(&self) -> Result<()> {
840        self.url_index_tree
841            .clear()
842            .context("Failed to clear URL index")?;
843
844        for result in self.scans_tree.iter() {
845            let (key, value) = result.context("Failed to iterate for index rebuild")?;
846            let record: ScanRecord =
847                decode_value(&value).context("Failed to deserialize for index rebuild")?;
848
849            let url_key = format!(
850                "{}_{}_{}",
851                record.url,
852                record.timestamp.timestamp_millis(),
853                record.id
854            );
855            self.url_index_tree
856                .insert(url_key.as_bytes(), &key)
857                .context("Failed to rebuild URL index entry")?;
858        }
859
860        Ok(())
861    }
862}
863
864#[cfg(test)]
865mod tests {
866    use super::*;
867    use tempfile::TempDir;
868
869    fn create_test_db() -> (HistoryDatabase, TempDir) {
870        let temp_dir = TempDir::new().unwrap();
871        let db = HistoryDatabase::new(Some(temp_dir.path().to_path_buf())).unwrap();
872        (db, temp_dir)
873    }
874
875    fn create_test_record(url: &str, timestamp: DateTime<Utc>) -> ScanRecord {
876        ScanRecord {
877            id: uuid::Uuid::new_v4().to_string(),
878            timestamp,
879            url: url.to_string(),
880            status: "200".to_string(),
881            detections: vec!["Nginx".to_string()],
882            content_findings: vec![],
883            tls_info: HashMap::new(),
884            response_time_ms: Some(150),
885            response_headers: HashMap::new(),
886            content_length: Some(1024),
887            desync_results: vec![],
888            screenshot_path: None,
889            robots_txt_content: None,
890            scan_config: ScanConfig {
891                timeout: 10,
892                http: true,
893                https: true,
894                detect_all: true,
895                content_analysis: false,
896                tls_analysis: false,
897                comprehensive_tls: false,
898                screenshot: false,
899                download_robots: false,
900                desync: false,
901                plugin_name: None,
902            },
903        }
904    }
905
906    #[test]
907    fn test_store_and_query_scans() {
908        let (db, _temp_dir) = create_test_db();
909        let now = Utc::now();
910        let record = create_test_record("https://example.com", now);
911
912        db.store_scan(&record).unwrap();
913
914        let query = HistoryQuery {
915            url_pattern: Some("example.com".to_string()),
916            ..Default::default()
917        };
918
919        let results = db.query_scans(&query).unwrap();
920        assert_eq!(results.len(), 1);
921        assert_eq!(results[0].url, "https://example.com");
922    }
923
924    #[test]
925    fn test_url_history() {
926        let (db, _temp_dir) = create_test_db();
927        let now = Utc::now();
928        let url = "https://example.com";
929
930        let record1 = create_test_record(url, now - chrono::Duration::days(1));
931        let record2 = create_test_record(url, now);
932
933        db.store_scan(&record1).unwrap();
934        db.store_scan(&record2).unwrap();
935
936        let history = db.get_url_history(url, Some(10)).unwrap();
937        assert_eq!(history.len(), 2);
938        assert!(history[0].timestamp > history[1].timestamp);
939    }
940
941    #[test]
942    fn test_database_stats() {
943        let (db, _temp_dir) = create_test_db();
944        let now = Utc::now();
945        let record = create_test_record("https://example.com", now);
946
947        db.store_scan(&record).unwrap();
948
949        let stats = db.get_database_stats().unwrap();
950        assert_eq!(stats.get("scans_count").unwrap(), &1);
951        assert!(stats.contains_key("size_bytes"));
952    }
953
954    #[test]
955    fn test_clean_old_data() {
956        let (db, _temp_dir) = create_test_db();
957        let now = Utc::now();
958
959        let old_record = create_test_record("https://old.com", now - chrono::Duration::days(10));
960        let new_record = create_test_record("https://new.com", now);
961
962        db.store_scan(&old_record).unwrap();
963        db.store_scan(&new_record).unwrap();
964
965        let cutoff = now - chrono::Duration::days(5);
966        let deleted = db.clean_old_data(cutoff).unwrap();
967
968        assert_eq!(deleted, 1);
969
970        let remaining = db.query_scans(&HistoryQuery::default()).unwrap();
971        assert_eq!(remaining.len(), 1);
972        assert_eq!(remaining[0].url, "https://new.com");
973    }
974
975    #[test]
976    fn test_batch_storage() {
977        let (db, _temp_dir) = create_test_db();
978        let now = Utc::now();
979
980        let records = vec![
981            create_test_record("https://example1.com", now),
982            create_test_record("https://example2.com", now),
983            create_test_record("https://example3.com", now),
984        ];
985
986        db.store_scans_batch(&records).unwrap();
987
988        let stored_records = db.query_scans(&HistoryQuery::default()).unwrap();
989        assert_eq!(stored_records.len(), 3);
990    }
991
992    #[test]
993    fn test_response_time_storage() {
994        let (db, _temp_dir) = create_test_db();
995        let now = Utc::now();
996
997        let mut record = create_test_record("https://example.com", now);
998        record.response_time_ms = Some(500);
999
1000        db.store_scan(&record).unwrap();
1001
1002        let stored_records = db.query_scans(&HistoryQuery::default()).unwrap();
1003        assert_eq!(stored_records.len(), 1);
1004        assert_eq!(stored_records[0].response_time_ms, Some(500));
1005    }
1006
1007    #[test]
1008    fn test_desync_results_query() {
1009        let (db, _temp_dir) = create_test_db();
1010        let now = Utc::now();
1011
1012        let mut record_with_desync = create_test_record("https://example.com", now);
1013        record_with_desync.desync_results = vec![];
1014
1015        let mut record_without_desync = create_test_record("https://example2.com", now);
1016        record_without_desync.desync_results = vec![];
1017
1018        db.store_scan(&record_with_desync).unwrap();
1019        db.store_scan(&record_without_desync).unwrap();
1020
1021        let query = HistoryQuery {
1022            has_desync_findings: Some(false),
1023            ..Default::default()
1024        };
1025
1026        let results = db.query_scans(&query).unwrap();
1027        assert_eq!(results.len(), 2);
1028    }
1029
1030    #[test]
1031    fn test_database_maintenance() {
1032        let (db, _temp_dir) = create_test_db();
1033        let now = Utc::now();
1034
1035        let record = create_test_record("https://example.com", now);
1036        db.store_scan(&record).unwrap();
1037
1038        let stats_before = db.get_database_stats().unwrap();
1039        assert_eq!(stats_before.get("scans_count").unwrap(), &1);
1040
1041        db.compact_database().unwrap();
1042
1043        let stats_after = db.get_database_stats().unwrap();
1044        assert_eq!(stats_after.get("scans_count").unwrap(), &1);
1045
1046        let integrity_issues = db.verify_database_integrity().unwrap();
1047        assert!(integrity_issues.is_empty());
1048    }
1049
1050    #[test]
1051    fn test_scan_comparison_with_new_fields() {
1052        let (db, _temp_dir) = create_test_db();
1053        let now = Utc::now();
1054        let earlier = now - chrono::Duration::hours(1);
1055
1056        let mut old_record = create_test_record("https://example.com", earlier);
1057        old_record.response_time_ms = Some(200);
1058        old_record.screenshot_path = None;
1059
1060        let mut new_record = create_test_record("https://example.com", now);
1061        new_record.response_time_ms = Some(500);
1062        new_record.screenshot_path = Some("screenshot.png".to_string());
1063
1064        db.store_scan(&old_record).unwrap();
1065        db.store_scan(&new_record).unwrap();
1066
1067        let comparison = db
1068            .compare_scans("https://example.com", earlier, now)
1069            .unwrap();
1070
1071        assert!(!comparison.changes.is_empty());
1072        assert!(comparison
1073            .changes
1074            .iter()
1075            .any(|c| c.contains("response time")));
1076        assert!(comparison
1077            .changes
1078            .iter()
1079            .any(|c| c.contains("Screenshot now available")));
1080    }
1081
1082    #[test]
1083    fn test_schema_migration_v1_to_v2() {
1084        let (mut db, _tmp) = create_test_db();
1085        // Simulate legacy v1 payloads
1086        let record = create_test_record("https://migrate.example", Utc::now());
1087        let key = format!("{}_{}", record.timestamp.timestamp_millis(), record.id);
1088        let legacy = bincode1::serialize(&record).expect("v1 serialize");
1089        db.scans_tree.insert(key.as_bytes(), legacy).unwrap();
1090        db.meta_tree.remove(SCHEMA_VERSION_KEY).ok();
1091        db.db.flush().unwrap();
1092
1093        assert_eq!(db.schema_version().unwrap(), 1);
1094        let report = db.migrate_v1_to_v2(false).unwrap();
1095        assert!(!report.skipped);
1096        assert_eq!(report.scans_migrated, 1);
1097        assert_eq!(db.schema_version().unwrap(), SCHEMA_VERSION);
1098
1099        let loaded = db
1100            .query_scans(&HistoryQuery {
1101                url_pattern: Some("migrate.example".into()),
1102                ..Default::default()
1103            })
1104            .unwrap();
1105        assert_eq!(loaded.len(), 1);
1106        assert_eq!(loaded[0].url, record.url);
1107
1108        let again = db.migrate_v1_to_v2(false).unwrap();
1109        assert!(again.skipped);
1110    }
1111
1112}