keyhog_scanner/engine/
recovery.rs1use crate::hw_probe::ScanBackend;
2
3const MAX_RECOVERY_REASON_BYTES: usize = 4096;
4const MISSING_RECOVERY_REASON: &str = "backend fault without diagnostic";
5
6#[derive(Clone, Debug, Eq, PartialEq)]
8pub struct RecoveredInputRange {
9 pub chunk_index: usize,
10 pub byte_start: usize,
11 pub byte_end: usize,
12}
13
14impl RecoveredInputRange {
15 pub fn new(chunk_index: usize, byte_start: usize, byte_end: usize) -> Self {
16 Self {
17 chunk_index,
18 byte_start,
19 byte_end,
20 }
21 }
22
23 #[must_use]
24 pub fn len(&self) -> usize {
25 self.byte_end.saturating_sub(self.byte_start)
26 }
27
28 #[must_use]
29 pub fn is_empty(&self) -> bool {
30 self.byte_start >= self.byte_end
31 }
32}
33
34#[derive(Clone, Debug, Eq, PartialEq)]
36pub struct BackendRecoveryReceipt {
37 pub failed_backend: ScanBackend,
38 pub recovery_backend: ScanBackend,
39 pub ranges: Vec<RecoveredInputRange>,
40 pub reason: String,
41}
42
43impl BackendRecoveryReceipt {
44 pub fn new(
45 failed_backend: ScanBackend,
46 recovery_backend: ScanBackend,
47 ranges: Vec<RecoveredInputRange>,
48 reason: String,
49 ) -> Self {
50 Self {
51 failed_backend,
52 recovery_backend,
53 ranges: canonicalize_ranges(ranges),
54 reason: sanitize_recovery_reason(reason),
55 }
56 }
57
58 #[must_use]
59 pub fn recovered_bytes(&self) -> u64 {
60 self.ranges
61 .iter()
62 .map(|range| u64::try_from(range.len()).unwrap_or(u64::MAX))
64 .fold(0u64, u64::saturating_add)
65 }
66
67 #[must_use]
68 pub fn recovered_chunks(&self) -> usize {
69 self.ranges
70 .iter()
71 .map(|range| range.chunk_index)
72 .collect::<std::collections::BTreeSet<_>>()
73 .len()
74 }
75}
76
77fn sanitize_recovery_reason(reason: String) -> String {
78 let mut sanitized = String::with_capacity(reason.len().min(MAX_RECOVERY_REASON_BYTES));
79 for ch in reason.chars() {
80 let ch = if ch.is_control() { '\u{fffd}' } else { ch };
81 if sanitized.len().saturating_add(ch.len_utf8()) > MAX_RECOVERY_REASON_BYTES {
82 break;
83 }
84 sanitized.push(ch);
85 }
86 if sanitized.is_empty() {
87 MISSING_RECOVERY_REASON.to_string()
88 } else {
89 sanitized
90 }
91}
92
93pub struct CoalescedScanOutcome {
95 pub matches: Vec<Vec<keyhog_core::RawMatch>>,
96 pub recovery: Option<BackendRecoveryReceipt>,
97}
98
99pub(crate) fn canonicalize_ranges(
102 mut ranges: Vec<RecoveredInputRange>,
103) -> Vec<RecoveredInputRange> {
104 ranges.retain(|range| !range.is_empty());
105 ranges.sort_unstable_by_key(|range| (range.chunk_index, range.byte_start, range.byte_end));
106 let mut canonical: Vec<RecoveredInputRange> = Vec::with_capacity(ranges.len());
107 for range in ranges {
108 if let Some(last) = canonical.last_mut() {
109 if last.chunk_index == range.chunk_index && range.byte_start <= last.byte_end {
110 last.byte_end = last.byte_end.max(range.byte_end);
111 continue;
112 }
113 }
114 canonical.push(range);
115 }
116 canonical
117}