Skip to main content

keyhog_scanner/engine/
recovery.rs

1use crate::hw_probe::ScanBackend;
2
3const MAX_RECOVERY_REASON_BYTES: usize = 4096;
4const MISSING_RECOVERY_REASON: &str = "backend fault without diagnostic";
5
6/// One exact source-byte interval completed after the selected backend faulted.
7#[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/// Complete, non-secret receipt for automatic recovery of stable input bytes.
35#[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            // LAW10: this is a diagnostic byte counter only; saturation cannot alter recovery candidates or findings.
63            .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
93/// Result of one fallible coalesced dispatch, including any completed recovery.
94pub struct CoalescedScanOutcome {
95    pub matches: Vec<Vec<keyhog_core::RawMatch>>,
96    pub recovery: Option<BackendRecoveryReceipt>,
97}
98
99// Tests live in `tests/unit/engine_recovery.rs` (KH-1308).
100
101pub(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}