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}