use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use rusqlite::{Connection, OpenFlags};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tempfile::TempDir;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvidenceProvenance {
pub original_path: PathBuf,
pub snapshot_path: Option<PathBuf>,
pub sha256: Option<String>,
pub copied_at: Option<SystemTime>,
}
#[derive(Debug)]
pub struct EvidenceDb {
pub conn: Connection,
pub provenance: EvidenceProvenance,
_snapshot_dir: Option<TempDir>,
}
pub fn open_evidence_db(path: &Path) -> rusqlite::Result<EvidenceDb> {
let wal = wal_sidecar(path);
let has_wal = fs::metadata(&wal).is_ok_and(|m| m.len() > 0);
if has_wal {
open_with_wal_snapshot(path).map_err(to_sqlite_err)
} else {
open_immutable_in_place(path)
}
}
fn open_immutable_in_place(path: &Path) -> rusqlite::Result<EvidenceDb> {
let uri = immutable_uri(path);
let conn = Connection::open_with_flags(
&uri,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
)?;
Ok(EvidenceDb {
conn,
provenance: EvidenceProvenance {
original_path: path.to_path_buf(),
snapshot_path: None,
sha256: None,
copied_at: None,
},
_snapshot_dir: None,
})
}
fn open_with_wal_snapshot(path: &Path) -> io::Result<EvidenceDb> {
let dir = TempDir::new()?;
let file_name = path.file_name().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"evidence path has no file name",
)
})?;
let copy_db = dir.path().join(file_name);
let sha256 = copy_and_hash(path, ©_db)?;
copy_if_exists(&wal_sidecar(path), &sidecar(©_db, "-wal"))?;
copy_if_exists(&shm_sidecar(path), &sidecar(©_db, "-shm"))?;
let conn = Connection::open_with_flags(©_db, OpenFlags::SQLITE_OPEN_READ_ONLY)
.map_err(io::Error::other)?;
Ok(EvidenceDb {
conn,
provenance: EvidenceProvenance {
original_path: path.to_path_buf(),
snapshot_path: Some(copy_db),
sha256: Some(sha256),
copied_at: Some(SystemTime::now()),
},
_snapshot_dir: Some(dir),
})
}
fn copy_and_hash(src: &Path, dst: &Path) -> io::Result<String> {
let bytes = fs::read(src)?;
let mut hasher = Sha256::new();
hasher.update(&bytes);
fs::write(dst, &bytes)?;
Ok(hex_lower(&hasher.finalize()))
}
fn copy_if_exists(src: &Path, dst: &Path) -> io::Result<()> {
if src.exists() {
fs::copy(src, dst)?;
}
Ok(())
}
fn hex_lower(bytes: &[u8]) -> String {
use std::fmt::Write as _;
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
let _ = write!(s, "{b:02x}");
}
s
}
fn immutable_uri(path: &Path) -> String {
format!(
"file:{}?immutable=1",
encode_uri_path(&path.to_string_lossy())
)
}
fn encode_uri_path(p: &str) -> String {
let mut out = String::with_capacity(p.len());
for ch in p.chars() {
match ch {
'?' | '#' | '%' => {
use std::fmt::Write as _;
let _ = write!(out, "%{:02X}", ch as u32);
}
' ' => out.push_str("%20"),
_ => out.push(ch),
}
}
out
}
fn sidecar(db_path: &Path, suffix: &str) -> PathBuf {
let mut s = db_path.as_os_str().to_os_string();
s.push(suffix);
PathBuf::from(s)
}
fn wal_sidecar(db_path: &Path) -> PathBuf {
sidecar(db_path, "-wal")
}
fn shm_sidecar(db_path: &Path) -> PathBuf {
sidecar(db_path, "-shm")
}
fn to_sqlite_err(e: io::Error) -> rusqlite::Error {
rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CANTOPEN),
Some(e.to_string()),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn immutable_uri_escapes_special_chars() {
let p = Path::new("/tmp/some dir/Hi story?x");
let uri = immutable_uri(p);
assert!(uri.starts_with("file:/tmp/some%20dir/Hi%20story%3Fx"));
assert!(uri.ends_with("?immutable=1"));
}
#[test]
fn hex_lower_is_64_chars_for_sha256() {
let h = Sha256::digest(b"hello");
assert_eq!(hex_lower(&h).len(), 64);
}
#[test]
fn sidecar_appends_suffix() {
let p = Path::new("/x/History");
assert_eq!(wal_sidecar(p), Path::new("/x/History-wal"));
assert_eq!(shm_sidecar(p), Path::new("/x/History-shm"));
}
}