Skip to main content

ext4fs/forensic/
carving.rs

1#![forbid(unsafe_code)]
2
3use crate::error::Result;
4use crate::forensic::recovery::BlockRange;
5use crate::inode::InodeReader;
6use crate::ondisk::extent::EXTENT_MAGIC;
7use std::io::{Read, Seek};
8
9/// A potential inode found by carving extent signatures.
10#[derive(Debug, Clone)]
11pub struct CarvedInode {
12    pub block: u64,
13    pub offset_in_block: usize,
14}
15
16/// Iterate block bitmaps and yield contiguous unallocated block ranges.
17pub fn unallocated_blocks<R: Read + Seek>(reader: &mut InodeReader<R>) -> Result<Vec<BlockRange>> {
18    let sb = reader.block_reader().superblock();
19    let group_count = reader.block_reader().group_count();
20    let bpg = u64::from(sb.blocks_per_group);
21    let blocks_count = sb.blocks_count;
22    let mut ranges = Vec::new();
23
24    for g in 0..group_count {
25        let bitmap_block = reader.block_reader_mut().block_bitmap_block(g)?;
26        let bitmap = reader.block_reader_mut().read_block(bitmap_block)?;
27        let base_block = u64::from(g) * bpg;
28
29        let mut run_start: Option<u64> = None;
30        let blocks_in_group = bpg.min(blocks_count.saturating_sub(base_block));
31
32        for bit in 0..blocks_in_group as usize {
33            let byte = bit / 8;
34            let bit_pos = bit % 8;
35            let allocated = if byte < bitmap.len() {
36                (bitmap[byte] >> bit_pos) & 1 == 1
37            } else {
38                false
39            };
40
41            if !allocated {
42                if run_start.is_none() {
43                    run_start = Some(base_block + bit as u64);
44                }
45            } else if let Some(start) = run_start {
46                let current = base_block + bit as u64;
47                ranges.push(BlockRange {
48                    start,
49                    length: current - start,
50                });
51                run_start = None;
52            }
53        }
54        if let Some(start) = run_start {
55            let end = base_block + blocks_in_group;
56            ranges.push(BlockRange {
57                start,
58                length: end - start,
59            });
60        }
61    }
62
63    Ok(ranges)
64}
65
66/// Read raw data from a contiguous unallocated range.
67pub fn read_unallocated<R: Read + Seek>(
68    reader: &mut InodeReader<R>,
69    range: &BlockRange,
70) -> Result<Vec<u8>> {
71    reader
72        .block_reader_mut()
73        .read_blocks(range.start, range.length)
74}
75
76/// Scan unallocated blocks for extent tree magic (0xF30A) — potential orphaned inodes.
77pub fn find_extent_signatures<R: Read + Seek>(
78    reader: &mut InodeReader<R>,
79    ranges: &[BlockRange],
80) -> Result<Vec<CarvedInode>> {
81    let mut found = Vec::new();
82
83    for range in ranges {
84        for i in 0..range.length {
85            let block = range.start + i;
86            let data = match reader.block_reader_mut().read_block(block) {
87                Ok(d) => d,
88                Err(_) => continue,
89            };
90            let mut offset = 0;
91            while offset + 12 <= data.len() {
92                if data.len() >= offset + 2 {
93                    let magic = u16::from_le_bytes([data[offset], data[offset + 1]]);
94                    if magic == EXTENT_MAGIC {
95                        found.push(CarvedInode {
96                            block,
97                            offset_in_block: offset,
98                        });
99                    }
100                }
101                offset += 12;
102            }
103        }
104    }
105
106    Ok(found)
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use crate::block::BlockReader;
113    use crate::inode::InodeReader;
114    use std::io::Cursor;
115
116    fn open_minimal() -> Option<InodeReader<Cursor<Vec<u8>>>> {
117        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/minimal.img");
118        let data = std::fs::read(path).ok()?;
119        let br = BlockReader::open(Cursor::new(data)).ok()?;
120        Some(InodeReader::new(br))
121    }
122
123    #[test]
124    fn find_unallocated_blocks() {
125        let mut reader = if let Some(r) = open_minimal() {
126            r
127        } else {
128            eprintln!("skip: minimal.img not found");
129            return;
130        };
131        let ranges = unallocated_blocks(&mut reader).unwrap();
132        assert!(!ranges.is_empty());
133        for range in &ranges {
134            assert!(range.length > 0);
135        }
136    }
137
138    fn open_forensic() -> Option<InodeReader<Cursor<Vec<u8>>>> {
139        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/forensic.img");
140        let data = std::fs::read(path).ok()?;
141        let br = BlockReader::open(Cursor::new(data)).ok()?;
142        Some(InodeReader::new(br))
143    }
144
145    #[test]
146    fn forensic_image_has_unallocated_blocks() {
147        let mut reader = if let Some(r) = open_forensic() {
148            r
149        } else {
150            eprintln!("skip: forensic.img not found");
151            return;
152        };
153        let ranges = unallocated_blocks(&mut reader).unwrap();
154        assert!(!ranges.is_empty(), "expected unallocated block ranges");
155        let total_free: u64 = ranges.iter().map(|r| r.length).sum();
156        assert!(
157            total_free > 100,
158            "expected > 100 free blocks in 32MB image, got {total_free}"
159        );
160    }
161
162    #[test]
163    fn read_unallocated_returns_data() {
164        let mut reader = if let Some(r) = open_forensic() {
165            r
166        } else {
167            eprintln!("skip: forensic.img not found");
168            return;
169        };
170        let ranges = unallocated_blocks(&mut reader).unwrap();
171        assert!(!ranges.is_empty(), "need at least one unallocated range");
172        let block_size = u64::from(reader.block_reader().superblock().block_size);
173        let first = &ranges[0];
174        let data = read_unallocated(&mut reader, first).unwrap();
175        assert_eq!(
176            data.len() as u64,
177            first.length * block_size,
178            "data length should equal range.length * block_size"
179        );
180    }
181
182    #[test]
183    fn find_extent_signatures_runs_without_error() {
184        let mut reader = if let Some(r) = open_forensic() {
185            r
186        } else {
187            eprintln!("skip: forensic.img not found");
188            return;
189        };
190        let ranges = unallocated_blocks(&mut reader).unwrap();
191        let carved = find_extent_signatures(&mut reader, &ranges).unwrap();
192        for c in &carved {
193            assert_eq!(
194                c.offset_in_block % 12,
195                0,
196                "offset {} is not 12-byte aligned",
197                c.offset_in_block
198            );
199        }
200    }
201}