Skip to main content

browser_forensic_core/
sqlite.rs

1//! Read-only, WAL-safe opening of browser SQLite evidence databases.
2//!
3//! Browser evidence DBs (`History`, `Cookies`, `places.sqlite`, …) must never be
4//! mutated. A naive read-**write** [`rusqlite::Connection::open`] can checkpoint an
5//! attached `-wal` on close, rewriting the main file — the cardinal sin for a
6//! forensic tool. [`open_evidence_db`] is the single, secure-by-default way the
7//! workspace opens such a file.
8//!
9//! ## WAL correctness
10//!
11//! SQLite's `immutable=1` URI flag makes a read-only open *ignore the `-wal`*,
12//! silently dropping the newest uncheckpointed rows. We therefore use
13//! `immutable=1` **only when there is no `-wal`**. When a non-empty `{path}-wal`
14//! sidecar exists, we copy the `{db, -wal, -shm}` working set into a disposable
15//! temp directory and open the **copy** `READ_ONLY` — the WAL is honored, and any
16//! checkpoint that SQLite chooses to perform lands on the throw-away copy, never
17//! the evidence.
18
19use std::fs;
20use std::io;
21use std::path::{Path, PathBuf};
22use std::time::SystemTime;
23
24use rusqlite::{Connection, OpenFlags};
25use serde::{Deserialize, Serialize};
26use sha2::{Digest, Sha256};
27use tempfile::TempDir;
28
29/// Provenance for an opened evidence database.
30///
31/// Surfaced additively alongside the connection so callers can record *how* the
32/// evidence was accessed without altering the existing `BrowserEvent.source`
33/// schema. When a WAL working-copy is made, `snapshot_path`, `sha256` (of the
34/// original main DB), and `copied_at` are populated; for a copy-free open they
35/// are `None`.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct EvidenceProvenance {
38    /// The original evidence path the caller requested.
39    pub original_path: PathBuf,
40    /// Path to the disposable working copy actually opened, when one was made.
41    pub snapshot_path: Option<PathBuf>,
42    /// SHA-256 (lowercase hex) of the original main DB file, when a copy was made.
43    pub sha256: Option<String>,
44    /// When the working copy was taken.
45    pub copied_at: Option<SystemTime>,
46}
47
48/// A read-only evidence SQLite connection plus its access provenance.
49///
50/// Holds an optional temp directory alive for the lifetime of the connection so
51/// the working copy is not reaped while in use; it is cleaned up on drop.
52#[derive(Debug)]
53pub struct EvidenceDb {
54    /// The read-only connection. Writes through it fail.
55    pub conn: Connection,
56    /// How the evidence was accessed (snapshot vs. in-place).
57    pub provenance: EvidenceProvenance,
58    /// Keeps the working-copy directory alive; `None` for a copy-free open.
59    _snapshot_dir: Option<TempDir>,
60}
61
62/// Open a browser SQLite evidence database **read-only and WAL-safe**.
63///
64/// This is the only sanctioned way to open an evidence DB in the workspace:
65/// the connection cannot write, and the original file is never checkpointed or
66/// otherwise mutated.
67///
68/// # Errors
69///
70/// Returns an error if the file cannot be read, the working copy cannot be
71/// written, or SQLite cannot open the (copy of the) database.
72pub fn open_evidence_db(path: &Path) -> rusqlite::Result<EvidenceDb> {
73    let wal = wal_sidecar(path);
74    let has_wal = fs::metadata(&wal).is_ok_and(|m| m.len() > 0);
75
76    if has_wal {
77        open_with_wal_snapshot(path).map_err(to_sqlite_err)
78    } else {
79        open_immutable_in_place(path)
80    }
81}
82
83/// No `-wal`: opening the original with `immutable=1` is safe and copy-free.
84fn open_immutable_in_place(path: &Path) -> rusqlite::Result<EvidenceDb> {
85    let uri = immutable_uri(path);
86    let conn = Connection::open_with_flags(
87        &uri,
88        OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
89    )?;
90    Ok(EvidenceDb {
91        conn,
92        provenance: EvidenceProvenance {
93            original_path: path.to_path_buf(),
94            snapshot_path: None,
95            sha256: None,
96            copied_at: None,
97        },
98        _snapshot_dir: None,
99    })
100}
101
102/// `-wal` present: copy `{db, -wal, -shm}` to a temp working set and open the
103/// copy `READ_ONLY` so the WAL is honored and any checkpoint hits the copy.
104fn open_with_wal_snapshot(path: &Path) -> io::Result<EvidenceDb> {
105    let dir = TempDir::new()?;
106    let file_name = path.file_name().ok_or_else(|| {
107        io::Error::new(
108            io::ErrorKind::InvalidInput,
109            "evidence path has no file name",
110        )
111    })?;
112    let copy_db = dir.path().join(file_name);
113
114    let sha256 = copy_and_hash(path, &copy_db)?;
115
116    // Copy sidecars if present; missing -shm is fine (SQLite recreates it).
117    copy_if_exists(&wal_sidecar(path), &sidecar(&copy_db, "-wal"))?;
118    copy_if_exists(&shm_sidecar(path), &sidecar(&copy_db, "-shm"))?;
119
120    let conn = Connection::open_with_flags(&copy_db, OpenFlags::SQLITE_OPEN_READ_ONLY)
121        .map_err(io::Error::other)?;
122
123    Ok(EvidenceDb {
124        conn,
125        provenance: EvidenceProvenance {
126            original_path: path.to_path_buf(),
127            snapshot_path: Some(copy_db),
128            sha256: Some(sha256),
129            copied_at: Some(SystemTime::now()),
130        },
131        _snapshot_dir: Some(dir),
132    })
133}
134
135/// Copy `src` to `dst` while computing the SHA-256 of the streamed bytes.
136fn copy_and_hash(src: &Path, dst: &Path) -> io::Result<String> {
137    let bytes = fs::read(src)?;
138    let mut hasher = Sha256::new();
139    hasher.update(&bytes);
140    fs::write(dst, &bytes)?;
141    Ok(hex_lower(&hasher.finalize()))
142}
143
144fn copy_if_exists(src: &Path, dst: &Path) -> io::Result<()> {
145    if src.exists() {
146        fs::copy(src, dst)?;
147    }
148    Ok(())
149}
150
151fn hex_lower(bytes: &[u8]) -> String {
152    use std::fmt::Write as _;
153    let mut s = String::with_capacity(bytes.len() * 2);
154    for b in bytes {
155        // Writing to a String is infallible.
156        let _ = write!(s, "{b:02x}");
157    }
158    s
159}
160
161/// Build a `file:` URI for `immutable=1` read-only open, percent-escaping the path.
162fn immutable_uri(path: &Path) -> String {
163    format!(
164        "file:{}?immutable=1",
165        encode_uri_path(&path.to_string_lossy())
166    )
167}
168
169/// Minimal percent-encoding for a filesystem path used in a SQLite `file:` URI.
170/// Encodes the characters SQLite's URI parser treats specially plus space.
171fn encode_uri_path(p: &str) -> String {
172    let mut out = String::with_capacity(p.len());
173    for ch in p.chars() {
174        match ch {
175            '?' | '#' | '%' => {
176                use std::fmt::Write as _;
177                let _ = write!(out, "%{:02X}", ch as u32);
178            }
179            ' ' => out.push_str("%20"),
180            _ => out.push(ch),
181        }
182    }
183    out
184}
185
186fn sidecar(db_path: &Path, suffix: &str) -> PathBuf {
187    let mut s = db_path.as_os_str().to_os_string();
188    s.push(suffix);
189    PathBuf::from(s)
190}
191
192fn wal_sidecar(db_path: &Path) -> PathBuf {
193    sidecar(db_path, "-wal")
194}
195
196fn shm_sidecar(db_path: &Path) -> PathBuf {
197    sidecar(db_path, "-shm")
198}
199
200fn to_sqlite_err(e: io::Error) -> rusqlite::Error {
201    rusqlite::Error::SqliteFailure(
202        rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CANTOPEN),
203        Some(e.to_string()),
204    )
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn immutable_uri_escapes_special_chars() {
213        let p = Path::new("/tmp/some dir/Hi story?x");
214        let uri = immutable_uri(p);
215        assert!(uri.starts_with("file:/tmp/some%20dir/Hi%20story%3Fx"));
216        assert!(uri.ends_with("?immutable=1"));
217    }
218
219    #[test]
220    fn hex_lower_is_64_chars_for_sha256() {
221        let h = Sha256::digest(b"hello");
222        assert_eq!(hex_lower(&h).len(), 64);
223    }
224
225    #[test]
226    fn sidecar_appends_suffix() {
227        let p = Path::new("/x/History");
228        assert_eq!(wal_sidecar(p), Path::new("/x/History-wal"));
229        assert_eq!(shm_sidecar(p), Path::new("/x/History-shm"));
230    }
231}