use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum StoreShape {
Absent,
Present,
Unusable(ShapeDefect),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ShapeDefect {
ZeroBytes,
TooShort(u64),
Unreadable,
BadMagic,
BadPageSize,
}
impl ShapeDefect {
#[must_use]
pub(crate) fn reason(self) -> String {
match self {
Self::ZeroBytes => "the data file is empty (0 bytes)".to_string(),
Self::TooShort(n) => {
format!("the data file is too short for a valid header ({n} bytes)")
}
Self::Unreadable => "the data file could not be read".to_string(),
Self::BadMagic => "the data file does not carry the SQLite header magic".to_string(),
Self::BadPageSize => "the SQLite header carries an invalid page size".to_string(),
}
}
#[must_use]
pub(crate) fn is_environment_caused(self) -> bool {
matches!(self, Self::Unreadable)
}
}
pub(crate) const WAL_HEADER_BYTES: u64 = 32;
pub(crate) fn stat_size(path: &Path) -> std::io::Result<u64> {
match std::fs::metadata(path) {
Ok(meta) => Ok(meta.len()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(0),
Err(e) => Err(e),
}
}
#[must_use]
pub(super) fn wal_size(db_path: &Path) -> u64 {
stat_size(&crate::db::wal_path(db_path)).unwrap_or(0)
}
#[must_use]
pub(crate) fn classify_store_shape(db_path: &Path) -> StoreShape {
let meta = match std::fs::metadata(db_path) {
Ok(meta) => meta,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return StoreShape::Absent,
Err(_) => return StoreShape::Unusable(ShapeDefect::Unreadable),
};
let size = meta.len();
if size == 0 {
return StoreShape::Unusable(ShapeDefect::ZeroBytes);
}
if size < DB_HEADER_MIN_SIZE {
return StoreShape::Unusable(ShapeDefect::TooShort(size));
}
let Some(header) = read_db_header(db_path) else {
return StoreShape::Unusable(ShapeDefect::Unreadable);
};
if &header[..16] != DB_HEADER_MAGIC {
return StoreShape::Unusable(ShapeDefect::BadMagic);
}
if !db_page_size_valid(&header) {
return StoreShape::Unusable(ShapeDefect::BadPageSize);
}
StoreShape::Present
}
const DB_HEADER_MAGIC: &[u8; 16] = b"SQLite format 3\0";
pub(crate) const DB_HEADER_MIN_SIZE: u64 = 100;
pub(crate) fn read_db_header(db_path: &Path) -> Option<[u8; 18]> {
use std::io::Read;
let mut header = [0u8; 18];
let mut file = std::fs::File::open(db_path).ok()?;
file.read_exact(&mut header).ok()?;
Some(header)
}
#[must_use]
pub(crate) fn db_page_size_valid(header: &[u8; 18]) -> bool {
let raw = u16::from_be_bytes([header[16], header[17]]);
let page_size = if raw == 1 { 65_536 } else { u32::from(raw) };
(512..=65_536).contains(&page_size) && page_size.is_power_of_two()
}
#[must_use]
pub(crate) fn db_header_valid(header: &[u8; 18]) -> bool {
&header[..16] == DB_HEADER_MAGIC && db_page_size_valid(header)
}
#[derive(Debug)]
pub(crate) struct UnusableStore {
pub store: &'static str,
pub db_path: std::path::PathBuf,
pub defect: ShapeDefect,
}
#[must_use]
pub(crate) fn scan_store_shapes(root: &Path) -> Vec<UnusableStore> {
let mut unusable = Vec::new();
for (name, _) in crate::db::iter_checkpoint_stores() {
let db_path = crate::db::store_db_path(root, name);
if let StoreShape::Unusable(defect) = classify_store_shape(&db_path) {
unusable.push(UnusableStore {
store: name,
db_path,
defect,
});
}
}
unusable
}
#[cfg(test)]
mod tests {
use super::*;
fn write(path: &std::path::Path, bytes: &[u8]) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, bytes).unwrap();
}
fn valid_header(page_size: u16) -> Vec<u8> {
let mut db = vec![0u8; 4096];
db[..16].copy_from_slice(b"SQLite format 3\0");
db[16..18].copy_from_slice(&page_size.to_be_bytes());
db
}
#[test]
fn db_header_valid_decodes_64k_page_size() {
let mut header = [0u8; 18];
header[..16].copy_from_slice(b"SQLite format 3\0");
header[16..18].copy_from_slice(&1u16.to_be_bytes());
assert!(db_header_valid(&header));
header[0] = b'X';
assert!(!db_header_valid(&header));
}
#[test]
fn classify_store_shape_classifies_synthetic_file_sets() {
let dir = std::env::temp_dir().join(format!("wal_guard_shape_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let db_path = dir.join("db/core.db");
let wal = dir.join("db/core.db-wal");
write(&db_path, &valid_header(4096));
assert_eq!(classify_store_shape(&db_path), StoreShape::Present);
write(&db_path, &[0u8; 64]);
assert_eq!(
classify_store_shape(&db_path),
StoreShape::Unusable(ShapeDefect::TooShort(64))
);
write(&db_path, &[]);
write(&wal, &[0u8; 512]);
assert_eq!(
classify_store_shape(&db_path),
StoreShape::Unusable(ShapeDefect::ZeroBytes)
);
let _ = std::fs::remove_file(&wal);
let _ = std::fs::remove_file(&db_path);
assert_eq!(classify_store_shape(&db_path), StoreShape::Absent);
write(&db_path, &[0x42; 128]);
assert_eq!(
classify_store_shape(&db_path),
StoreShape::Unusable(ShapeDefect::BadMagic)
);
let mut bad_page = valid_header(4096);
bad_page[16..18].copy_from_slice(&0u16.to_be_bytes());
write(&db_path, &bad_page);
assert_eq!(
classify_store_shape(&db_path),
StoreShape::Unusable(ShapeDefect::BadPageSize)
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn classify_store_shape_visits_every_store() {
let dir = std::env::temp_dir().join(format!("wal_guard_all_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
write(&dir.join("db/core.db"), &valid_header(4096));
write(&dir.join("db/logs.db"), &valid_header(4096));
for name in crate::db::store_names() {
assert_eq!(
classify_store_shape(&crate::db::store_db_path(&dir, name)),
StoreShape::Present,
"fixture store {name} must be present"
);
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn wal_size_reports_the_wal_size() {
let dir = std::env::temp_dir().join(format!("wal_guard_facts_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let db_path = dir.join("db/core.db");
write(&db_path, &valid_header(4096));
write(&dir.join("db/core.db-wal"), &[0xAA; 512]);
assert_eq!(wal_size(&db_path), 512);
let _ = std::fs::remove_file(dir.join("db/core.db-wal"));
assert_eq!(wal_size(&db_path), 0);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn scan_store_shapes_reports_every_unusable_file() {
let dir = std::env::temp_dir().join(format!("wal_guard_scan_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let core = dir.join("db/core.db");
let logs = dir.join("db/logs.db");
write(&core, &[]);
write(&logs, &[0x42; 128]);
let unusable = scan_store_shapes(&dir);
let named: Vec<&str> = unusable.iter().map(|u| u.store).collect();
assert_eq!(named, ["core", "logs"], "every unusable store is reported");
assert_eq!(unusable[0].defect, ShapeDefect::ZeroBytes);
assert!(unusable[0].db_path.ends_with("core.db"));
write(&core, &valid_header(4096));
let _ = std::fs::remove_file(&logs);
assert!(scan_store_shapes(&dir).is_empty());
let _ = std::fs::remove_file(&core);
assert!(scan_store_shapes(&dir).is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn classify_store_shape_reports_an_unreadable_file() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("wal_guard_unreadable_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let db_path = dir.join("db/core.db");
write(&db_path, &valid_header(4096));
std::fs::set_permissions(&db_path, std::fs::Permissions::from_mode(0o000)).unwrap();
if std::fs::read(&db_path).is_ok() {
let _ = std::fs::remove_dir_all(&dir);
return;
}
assert_eq!(
classify_store_shape(&db_path),
StoreShape::Unusable(ShapeDefect::Unreadable),
);
std::fs::set_permissions(&db_path, std::fs::Permissions::from_mode(0o644)).unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
}