Skip to main content

ext4fs/forensic/
search.rs

1#![forbid(unsafe_code)]
2
3use crate::error::Result;
4use crate::inode::InodeReader;
5use std::io::{Read, Seek};
6
7/// A search hit: pattern found at a specific location.
8#[derive(Debug, Clone)]
9pub struct SearchHit {
10    /// Physical block number containing the hit.
11    pub block: u64,
12    /// Byte offset within the block.
13    pub offset: usize,
14    /// Context bytes around the hit (surrounding data for examiner review).
15    pub context: Vec<u8>,
16}
17
18/// Which blocks to search.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum SearchScope {
21    /// Only allocated blocks.
22    Allocated,
23    /// Only unallocated blocks.
24    Unallocated,
25    /// All blocks on the device.
26    All,
27}
28
29/// Search for a byte pattern across blocks.
30///
31/// `context_bytes` controls how many bytes before/after the match to include
32/// in each `SearchHit.context` (default suggestion: 32).
33pub fn search_blocks<R: Read + Seek>(
34    reader: &mut InodeReader<R>,
35    pattern: &[u8],
36    scope: SearchScope,
37    context_bytes: usize,
38) -> Result<Vec<SearchHit>> {
39    if pattern.is_empty() {
40        return Ok(Vec::new());
41    }
42
43    let sb = reader.block_reader().superblock();
44    let blocks_count = sb.blocks_count;
45    let mut hits = Vec::new();
46
47    for block_num in 0..blocks_count {
48        // Check if we should search this block based on scope
49        let allocated = reader.is_block_allocated(block_num).unwrap_or(true);
50        match scope {
51            SearchScope::Allocated => {
52                if !allocated {
53                    continue;
54                }
55            }
56            SearchScope::Unallocated => {
57                if allocated {
58                    continue;
59                }
60            }
61            SearchScope::All => {}
62        }
63
64        let data = match reader.block_reader_mut().read_block(block_num) {
65            Ok(d) => d,
66            Err(_) => continue,
67        };
68
69        // Simple byte pattern search within this block
70        let mut pos = 0;
71        while pos + pattern.len() <= data.len() {
72            if &data[pos..pos + pattern.len()] == pattern {
73                // Extract context
74                let ctx_start = pos.saturating_sub(context_bytes);
75                let ctx_end = (pos + pattern.len() + context_bytes).min(data.len());
76                let context = data[ctx_start..ctx_end].to_vec();
77
78                hits.push(SearchHit {
79                    block: block_num,
80                    offset: pos,
81                    context,
82                });
83                pos += pattern.len(); // skip past this match
84            } else {
85                pos += 1;
86            }
87        }
88    }
89
90    Ok(hits)
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use crate::block::BlockReader;
97    use std::io::Cursor;
98
99    fn open_forensic() -> Option<InodeReader<Cursor<Vec<u8>>>> {
100        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/forensic.img");
101        let data = std::fs::read(path).ok()?;
102        let br = BlockReader::open(Cursor::new(data)).ok()?;
103        Some(InodeReader::new(br))
104    }
105
106    #[test]
107    fn search_hello_in_allocated() {
108        let mut reader = if let Some(r) = open_forensic() {
109            r
110        } else {
111            eprintln!("skip");
112            return;
113        };
114        let hits = search_blocks(&mut reader, b"Hello", SearchScope::Allocated, 16).unwrap();
115        assert!(!hits.is_empty(), "should find 'Hello' in allocated blocks");
116        for h in &hits {
117            assert!(h.context.windows(5).any(|w| w == b"Hello"));
118        }
119    }
120
121    #[test]
122    fn search_nonexistent_pattern() {
123        let mut reader = if let Some(r) = open_forensic() {
124            r
125        } else {
126            eprintln!("skip");
127            return;
128        };
129        let hits = search_blocks(
130            &mut reader,
131            b"ZZZZZ_DEFINITELY_NOT_HERE_12345",
132            SearchScope::All,
133            16,
134        )
135        .unwrap();
136        assert!(hits.is_empty());
137    }
138
139    #[test]
140    fn search_empty_pattern_returns_empty() {
141        let mut reader = if let Some(r) = open_forensic() {
142            r
143        } else {
144            eprintln!("skip");
145            return;
146        };
147        let hits = search_blocks(&mut reader, b"", SearchScope::All, 16).unwrap();
148        assert!(hits.is_empty());
149    }
150
151    #[test]
152    fn search_ext4_magic_in_all() {
153        let mut reader = if let Some(r) = open_forensic() {
154            r
155        } else {
156            eprintln!("skip");
157            return;
158        };
159        // Search for the label "forensic-test" which was set during mkfs
160        let hits = search_blocks(&mut reader, b"forensic-test", SearchScope::All, 8).unwrap();
161        assert!(
162            !hits.is_empty(),
163            "should find 'forensic-test' label in superblock"
164        );
165    }
166
167    #[test]
168    fn search_unallocated_scope() {
169        let mut reader = if let Some(r) = open_forensic() {
170            r
171        } else {
172            eprintln!("skip");
173            return;
174        };
175        // Search for deleted file content in unallocated blocks
176        // "recover me" was in deleted-file.txt
177        let hits = search_blocks(&mut reader, b"recover me", SearchScope::Unallocated, 16).unwrap();
178        // May or may not find it depending on whether blocks were zeroed
179        let _ = hits; // just verify no error
180    }
181
182    #[test]
183    fn search_hit_has_valid_context() {
184        let mut reader = if let Some(r) = open_forensic() {
185            r
186        } else {
187            eprintln!("skip");
188            return;
189        };
190        let hits = search_blocks(&mut reader, b"Hello", SearchScope::Allocated, 32).unwrap();
191        if let Some(hit) = hits.first() {
192            assert!(!hit.context.is_empty());
193            // Context should be at least pattern length
194            assert!(hit.context.len() >= 5);
195        }
196    }
197}