feat: end-to-end integrity checks with self-healing; website; installer and Windows fixes
Store::verify compares the whole SQLite projection, every table row for row, against a fresh projection of the journal; verify_and_heal rebuilds a drifted database (keeping the old file) at every server start and via the new books.verify tool, which also re-hashes every blob. The GUI checks the server's database read-only and every stored file, and shows the result. rebuild() also drops stale WAL/SHM files. site/: the wanderbooks website (just site, just publish). install.sh: OS detection (Windows points to WSL2), C compiler and git checks, WANDERBOOKS_REPO. Windows: no directory fsync off Unix; .exe-aware lookup. Repo justfile: check, build, install, demo, site. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
This commit is contained in:
25 files changed
+808
-41
No files matched your search
@@ -60,9 +60,14 @@ impl BlobStore {
|
||||
f.sync_all()?;
|
||||
}
|
||||
fs::rename(&tmp, &path)?;
|
||||
// Fsync the directory so the rename itself is durable.
|
||||
let dir = fs::File::open(path.parent().unwrap())?;
|
||||
dir.sync_all()?;
|
||||
// Fsync the directory so the rename itself is durable. Unix only:
|
||||
// Windows can't open a directory as a file, and NTFS journals the
|
||||
// rename's metadata itself.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let dir = fs::File::open(path.parent().unwrap())?;
|
||||
dir.sync_all()?;
|
||||
}
|
||||
Ok((sha, false))
|
||||
}
|
||||
|
||||
|
||||
@@ -28,8 +28,8 @@ pub mod store;
|
||||
pub use blob::BlobStore;
|
||||
pub use error::StoreError;
|
||||
pub use journal::{GENESIS_PREV, JournalLine, JournalWriter};
|
||||
pub use snapshot::Snapshot;
|
||||
pub use snapshot::{DiskCheck, Snapshot};
|
||||
pub use store::{
|
||||
Attachment, DB_FILE, DepreciationRun, DisposalPreview, EntryRow, EventRow, JOURNAL_FILE,
|
||||
PayrollLineInput, Store,
|
||||
PayrollLineInput, Store, VerifyReport,
|
||||
};
|
||||
@@ -18,14 +18,36 @@ use crate::blob::BlobStore;
|
||||
use crate::error::StoreError;
|
||||
use crate::journal::{GENESIS_PREV, JournalLine, sha256_hex};
|
||||
use crate::store::{
|
||||
Attachment, BLOBS_DIR, JOURNAL_FILE, Store, apply_line_to_sqlite, query_attachments,
|
||||
read_attachment_bytes, replay_line_into_ledger,
|
||||
Attachment, BLOBS_DIR, DB_FILE, JOURNAL_FILE, Store, apply_line_to_sqlite, compare_projections,
|
||||
projection_rows, query_attachments, read_attachment_bytes, replay_line_into_ledger,
|
||||
};
|
||||
use ledger_core::{Currency, EntryId, Ledger};
|
||||
use rusqlite::Connection;
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// How the writer's on-disk SQLite database compares with the journal.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum DiskCheck {
|
||||
/// Every table matches a fresh projection of the journal.
|
||||
Matches,
|
||||
/// The database hasn't applied the newest journal lines yet (normal
|
||||
/// for a moment after a write; the writer applies right after
|
||||
/// journaling, and heals on its next start).
|
||||
Behind {
|
||||
/// Journal lines the database has applied.
|
||||
applied: u64,
|
||||
/// Journal lines there are.
|
||||
journal: u64,
|
||||
},
|
||||
/// No database file (the writer rebuilds it on start).
|
||||
Missing,
|
||||
/// The database differs from the journal: what differs.
|
||||
Differs(String),
|
||||
/// The database couldn't be read.
|
||||
Unreadable(String),
|
||||
}
|
||||
|
||||
/// A read-only, live-refreshable view of a data directory.
|
||||
pub struct Snapshot {
|
||||
data_dir: PathBuf,
|
||||
@@ -168,6 +190,58 @@ impl Snapshot {
|
||||
query_attachments(&self.conn, entry_id)
|
||||
}
|
||||
|
||||
/// Compare the writer's SQLite database (opened read-only) with this
|
||||
/// snapshot's own projection of the journal, every table, row for row.
|
||||
pub fn check_disk(&self) -> DiskCheck {
|
||||
use rusqlite::OpenFlags;
|
||||
let path = self.data_dir.join(DB_FILE);
|
||||
if !path.exists() {
|
||||
return DiskCheck::Missing;
|
||||
}
|
||||
let conn = match Connection::open_with_flags(
|
||||
&path,
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return DiskCheck::Unreadable(e.to_string()),
|
||||
};
|
||||
let applied: u64 = match conn.query_row(
|
||||
"SELECT value FROM meta WHERE key='last_applied_seq'",
|
||||
[],
|
||||
|r| r.get::<_, String>(0),
|
||||
) {
|
||||
Ok(v) => v.parse().unwrap_or(0),
|
||||
Err(e) => return DiskCheck::Unreadable(e.to_string()),
|
||||
};
|
||||
let journal = self.journal_seq();
|
||||
if applied != journal {
|
||||
return DiskCheck::Behind { applied, journal };
|
||||
}
|
||||
let (disk, ours) = match (projection_rows(&conn), projection_rows(&self.conn)) {
|
||||
(Ok(d), Ok(o)) => (d, o),
|
||||
(Err(e), _) | (_, Err(e)) => return DiskCheck::Unreadable(e.to_string()),
|
||||
};
|
||||
match compare_projections(&disk, &ours) {
|
||||
Ok(()) => DiskCheck::Matches,
|
||||
Err(StoreError::VerifyMismatch(why)) => DiskCheck::Differs(why),
|
||||
Err(e) => DiskCheck::Unreadable(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-hash every stored file. Returns how many verified, and each
|
||||
/// failure as (attachment id, why).
|
||||
pub fn verify_blobs(&self) -> (u64, Vec<(u64, String)>) {
|
||||
let mut ok = 0;
|
||||
let mut bad = Vec::new();
|
||||
for a in self.attachments(None).unwrap_or_default() {
|
||||
match self.blobs.get(&a.blob_sha) {
|
||||
Ok(_) => ok += 1,
|
||||
Err(e) => bad.push((a.id, e.to_string())),
|
||||
}
|
||||
}
|
||||
(ok, bad)
|
||||
}
|
||||
|
||||
/// An attachment's bytes, verified against its hash.
|
||||
pub fn attachment_bytes(&self, attachment_id: u64) -> Result<Vec<u8>, StoreError> {
|
||||
read_attachment_bytes(&self.conn, &self.blobs, attachment_id)
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
|
||||
use crate::blob::BlobStore;
|
||||
use crate::error::StoreError;
|
||||
use crate::journal::{JournalLine, JournalWriter, read_and_verify_journal};
|
||||
use crate::journal::{
|
||||
GENESIS_PREV, JournalLine, JournalWriter, read_and_verify_journal, sha256_hex,
|
||||
};
|
||||
use crate::schema;
|
||||
use chrono::{Months, NaiveDate};
|
||||
use ledger_core::{
|
||||
@@ -2257,7 +2259,44 @@ impl Store {
|
||||
"payroll runs diverged between journal, live ledger, and sqlite projection".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
// Finally, the whole projection, not just what reports read: every
|
||||
// table, row for row, against a fresh projection of the journal.
|
||||
compare_projections(
|
||||
&projection_rows(&self.conn)?,
|
||||
&projection_rows(&fresh_projection(&lines)?)?,
|
||||
)
|
||||
}
|
||||
|
||||
/// [`Self::verify`], and if the SQLite projection is what diverged,
|
||||
/// heal it: keep the divergent file for forensics, rebuild from the
|
||||
/// journal, and verify again. A journal that fails its own chain is
|
||||
/// never "healed": that's an error for a human.
|
||||
pub fn verify_and_heal(&mut self) -> Result<VerifyReport, StoreError> {
|
||||
let healed = match self.verify() {
|
||||
Ok(()) => None,
|
||||
Err(StoreError::VerifyMismatch(why)) => {
|
||||
let stamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
|
||||
let keep = self.db_path.with_extension(format!("mismatch.{stamp}"));
|
||||
std::fs::copy(&self.db_path, &keep)?;
|
||||
eprintln!(
|
||||
"wanderbooks: {why}; kept the old database as {} and rebuilt from the journal",
|
||||
keep.display()
|
||||
);
|
||||
self.rebuild()?;
|
||||
self.verify()?;
|
||||
Some(why)
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
let lines = read_and_verify_journal(self.journal.path())?;
|
||||
Ok(VerifyReport {
|
||||
events: lines.len() as u64,
|
||||
head: lines
|
||||
.last()
|
||||
.map(|l| sha256_hex(l.to_canonical().as_bytes()))
|
||||
.unwrap_or_else(|| GENESIS_PREV.to_string()),
|
||||
healed,
|
||||
})
|
||||
}
|
||||
|
||||
/// Verify every blob's bytes against its SHA-256. Returns the count
|
||||
@@ -2282,6 +2321,10 @@ impl Store {
|
||||
let old = std::mem::replace(&mut self.conn, Connection::open_in_memory()?);
|
||||
old.close().map_err(|(_, e)| StoreError::Rusqlite(e))?;
|
||||
std::fs::remove_file(&self.db_path)?;
|
||||
// WAL and SHM are derived state too; a stale WAL must not leak into
|
||||
// the rebuilt database.
|
||||
let _ = std::fs::remove_file(self.db_path.with_extension("db-wal"));
|
||||
let _ = std::fs::remove_file(self.db_path.with_extension("db-shm"));
|
||||
self.conn = Self::open_conn(&self.db_path)?;
|
||||
Self::migrate(&self.conn)?;
|
||||
Self::write_meta(&self.conn, "base_currency", self.base_currency.code())?;
|
||||
@@ -3774,6 +3817,93 @@ pub(crate) fn apply_line_to_sqlite(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What [`Store::verify_and_heal`] found.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct VerifyReport {
|
||||
/// Journal events verified.
|
||||
pub events: u64,
|
||||
/// Hash of the last journal line: the verified head of the chain.
|
||||
pub head: String,
|
||||
/// Why the projection was rebuilt, if it was.
|
||||
pub healed: Option<String>,
|
||||
}
|
||||
|
||||
/// A projection of `lines` into a private in-memory database, built with
|
||||
/// exactly the code the writer uses.
|
||||
pub(crate) fn fresh_projection(lines: &[JournalLine]) -> Result<Connection, StoreError> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
Store::migrate(&conn)?;
|
||||
for line in lines {
|
||||
apply_line_to_sqlite(&conn, line)?;
|
||||
}
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
/// Every row of every projected table, as canonical strings, sorted: two
|
||||
/// projections hold the same data exactly when these are equal. The `meta`
|
||||
/// table (bookkeeping about the projection itself) is left out.
|
||||
pub(crate) fn projection_rows(
|
||||
conn: &Connection,
|
||||
) -> Result<std::collections::BTreeMap<String, Vec<String>>, StoreError> {
|
||||
use rusqlite::types::ValueRef;
|
||||
let tables: Vec<String> = conn
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name <> 'meta' ORDER BY name",
|
||||
)?
|
||||
.query_map([], |r| r.get(0))?
|
||||
.collect::<Result<_, _>>()?;
|
||||
let mut out = std::collections::BTreeMap::new();
|
||||
for t in tables {
|
||||
let mut stmt = conn.prepare(&format!("SELECT * FROM \"{t}\""))?;
|
||||
let n = stmt.column_count();
|
||||
let mut rows: Vec<String> = stmt
|
||||
.query_map([], |r| {
|
||||
let mut s = String::new();
|
||||
for i in 0..n {
|
||||
let v = match r.get_ref(i)? {
|
||||
ValueRef::Null => "∅".to_string(),
|
||||
ValueRef::Integer(x) => format!("i{x}"),
|
||||
ValueRef::Real(x) => format!("r{x}"),
|
||||
ValueRef::Text(b) => format!("t{}", String::from_utf8_lossy(b)),
|
||||
ValueRef::Blob(b) => format!("b{}", sha256_hex(b)),
|
||||
};
|
||||
s.push_str(&v);
|
||||
s.push('\u{1f}');
|
||||
}
|
||||
Ok(s)
|
||||
})?
|
||||
.collect::<Result<_, _>>()?;
|
||||
rows.sort();
|
||||
out.insert(t, rows);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Compare two projections; name the first table that differs.
|
||||
pub(crate) fn compare_projections(
|
||||
have: &std::collections::BTreeMap<String, Vec<String>>,
|
||||
want: &std::collections::BTreeMap<String, Vec<String>>,
|
||||
) -> Result<(), StoreError> {
|
||||
for (table, rows) in want {
|
||||
match have.get(table) {
|
||||
None => {
|
||||
return Err(StoreError::VerifyMismatch(format!(
|
||||
"sqlite projection is missing table {table}"
|
||||
)));
|
||||
}
|
||||
Some(h) if h != rows => {
|
||||
let missing = rows.iter().filter(|r| !h.contains(r)).count();
|
||||
let extra = h.iter().filter(|r| !rows.contains(r)).count();
|
||||
return Err(StoreError::VerifyMismatch(format!(
|
||||
"sqlite projection differs from the journal in table {table}: {missing} rows missing, {extra} unexpected"
|
||||
)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Attachments in the projection, optionally only one entry's, by id.
|
||||
pub(crate) fn query_attachments(
|
||||
conn: &Connection,
|
||||
|
||||
Reference in new issue
Block a user