1use 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
18pub 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 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 }
234 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 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}