Skip to main content

sqlite_forensic/
carve.rs

1//! Whole-database SQLite carver satisfying the fleet [`forensic_carve::Carver`]
2//! contract (ADR 0001 §4).
3//!
4//! A disk-unallocated or memory sweep hits the `SQLite format 3\0` magic and
5//! hands this carver a capped window starting at that magic. The carver validates
6//! the 100-byte file header (magic, page size, in-header page count, reserved
7//! space), bounds the database to `page_size × page_count`, and emits the bounded
8//! byte range as a [`forensic_carve::CarvedItem`] that re-enters the normal
9//! classify → parse pipeline. It is **medium-agnostic**: it echoes
10//! [`CarveContext::recovery_method`] (so the *same* carver stamps `UnallocatedCarve`
11//! on a disk sweep and `MemoryCarve` on a memory sweep) and never touches a
12//! `Read`/`Seek`, a VFS handle, or a memory provider.
13//!
14//! Header field offsets come from `forensicnomicon::sqlite` (the KNOWLEDGE leaf) —
15//! the same constants `sqlite-core`'s reader parses, kept in one place. All reads
16//! are bounds-checked; a window that fails validation emits nothing.
17
18use forensic_carve::{CarveContext, CarvedItem, Carver, CarverRegistration, Signature};
19use forensicnomicon::sqlite::{
20    SQLITE_DB_SIZE_OFFSET, SQLITE_HEADER_SIZE, SQLITE_MAGIC, SQLITE_PAGE_SIZE_OFFSET,
21    SQLITE_RESERVED_SPACE_OFFSET, SQLITE_TEXT_ENCODING_OFFSET,
22};
23
24/// The format id every carved SQLite item advertises.
25const FORMAT: &str = "sqlite";
26
27/// Upper bound on the bytes one hit may claim, so the sweep engine caps the window
28/// it materializes. SQLite's in-header page count is a `u32`, so a maximal image is
29/// enormous; `1 GiB` comfortably covers real-world evidence databases (browser,
30/// messaging, OS artifact stores are tens of MB) while bounding a lying header.
31const MAX_WINDOW: u64 = 1 << 30;
32
33/// The single header-magic signature that anchors a SQLite candidate window.
34static SIGNATURES: [Signature; 1] = [Signature::new(b"SQLite format 3\0", 0)];
35
36/// Whole-database SQLite carver (see the module docs).
37#[derive(Debug, Clone, Copy, Default)]
38pub struct SqliteCarver;
39
40/// The registered instance the fleet sweep engine collects at link time.
41static SQLITE_CARVER: SqliteCarver = SqliteCarver;
42
43inventory::submit! { CarverRegistration::new(&SQLITE_CARVER) }
44
45/// Read a big-endian `u16` at `off`, or `None` if the window is too short.
46fn be_u16(bytes: &[u8], off: usize) -> Option<u16> {
47    let end = off.checked_add(2)?;
48    let slice = bytes.get(off..end)?;
49    Some(u16::from_be_bytes([slice[0], slice[1]]))
50}
51
52/// Read a big-endian `u32` at `off`, or `None` if the window is too short.
53fn be_u32(bytes: &[u8], off: usize) -> Option<u32> {
54    let end = off.checked_add(4)?;
55    let slice = bytes.get(off..end)?;
56    Some(u32::from_be_bytes([slice[0], slice[1], slice[2], slice[3]]))
57}
58
59impl Carver for SqliteCarver {
60    fn format(&self) -> &'static str {
61        FORMAT
62    }
63
64    fn signatures(&self) -> &[Signature] {
65        &SIGNATURES
66    }
67
68    fn max_window(&self) -> u64 {
69        MAX_WINDOW
70    }
71
72    fn carve(&self, window: &[u8], ctx: &CarveContext) -> Vec<CarvedItem> {
73        // A valid database is at least a full 100-byte header.
74        let Some(header) = window.get(..SQLITE_HEADER_SIZE) else {
75            return Vec::new();
76        };
77
78        // Check 1 (mandatory): the file-header magic.
79        if !header.starts_with(SQLITE_MAGIC) {
80            return Vec::new();
81        }
82
83        // Check 2 (mandatory): a valid page size. Byte 16 (BE u16); the special
84        // value 1 encodes 65536. Must be a power of two in [512, 65536].
85        let Some(raw_page_size) = be_u16(header, SQLITE_PAGE_SIZE_OFFSET) else {
86            return Vec::new(); // cov:unreachable: header is >= 100 bytes here
87        };
88        let page_size: u32 = if raw_page_size == 1 {
89            65536
90        } else {
91            u32::from(raw_page_size)
92        };
93        if !(512..=65536).contains(&page_size) || !page_size.is_power_of_two() {
94            return Vec::new();
95        }
96
97        // Check 3 (mandatory): a non-zero in-header page count (byte 28, BE u32).
98        // Zero is the legacy "derive size from file length" encoding, which a
99        // detached carving window cannot honor — so it is not a bounded DB here.
100        let Some(page_count) = be_u32(header, SQLITE_DB_SIZE_OFFSET) else {
101            return Vec::new(); // cov:unreachable: header is >= 100 bytes here
102        };
103        if page_count == 0 {
104            return Vec::new();
105        }
106
107        // Grading checks (independent header signals — each raises confidence).
108        // Reserved space (byte 20) must leave a positive usable page size.
109        let reserved = header
110            .get(SQLITE_RESERVED_SPACE_OFFSET)
111            .copied()
112            .unwrap_or(0);
113        let reserved_sane = u32::from(reserved) < page_size;
114        // Text encoding (byte 56, BE u32): 1 = UTF-8, 2 = UTF-16LE, 3 = UTF-16BE.
115        let encoding_valid = matches!(be_u32(header, SQLITE_TEXT_ENCODING_OFFSET), Some(1..=3));
116
117        // Bound the database to page_size * page_count, clamped to the window and
118        // the max-window cap.
119        let declared_len = u64::from(page_size) * u64::from(page_count);
120        let full_db_present = declared_len <= window.len() as u64;
121        let bounded_len = declared_len.min(window.len() as u64).min(MAX_WINDOW);
122        let end = usize::try_from(bounded_len).unwrap_or(usize::MAX);
123        let Some(db_bytes) = window.get(..end) else {
124            return Vec::new(); // cov:unreachable: end <= window.len() by the min above
125        };
126
127        // Confidence = fraction of independent header checks that passed. The three
128        // mandatory checks always hold here (a floor of 3/6); the three grading
129        // checks distinguish a fully-consistent header from a bare magic + size.
130        let graded = [reserved_sane, encoding_valid, full_db_present]
131            .iter()
132            .filter(|p| **p)
133            .count();
134        #[allow(clippy::cast_precision_loss)]
135        let confidence = (3 + graded) as f32 / 6.0;
136
137        vec![CarvedItem::artifact_bytes(
138            FORMAT,
139            ctx.base_offset(),
140            confidence,
141            ctx.recovery_method(),
142            db_bytes.to_vec(),
143        )]
144    }
145}