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
@@ -361,16 +361,33 @@ fn live_status(ui: &mut Ui, b: &Books, since_update: f64, live: bool) {
|
||||
.color(p.muted),
|
||||
);
|
||||
});
|
||||
ui.label(
|
||||
RichText::new(format!("{} Journal chain verified", icon::SEAL_CHECK))
|
||||
.font(theme::regular(11.5))
|
||||
.color(p.muted),
|
||||
)
|
||||
.on_hover_text(format!(
|
||||
"Every journal event's hash chains to the one before it.\nHead: {}\nData: {}",
|
||||
b.head,
|
||||
b.data_dir.display()
|
||||
));
|
||||
let check = |ui: &mut Ui, ok: bool, text: String, tip: String| {
|
||||
let (glyph, color) = if ok { (icon::SEAL_CHECK, p.muted) } else { (icon::WARNING_CIRCLE, p.negative) };
|
||||
ui.label(RichText::new(format!("{glyph} {text}")).font(theme::regular(11.5)).color(color))
|
||||
.on_hover_text(tip);
|
||||
};
|
||||
check(
|
||||
ui,
|
||||
true,
|
||||
"Journal chain verified".into(),
|
||||
format!("Every journal event's hash chains to the one before it.\nHead: {}\nData: {}", b.head, b.data_dir.display()),
|
||||
);
|
||||
use ledger_store::DiskCheck;
|
||||
let (ok, text, tip) = match &b.integrity.disk {
|
||||
DiskCheck::Matches => (true, "Database matches journal".to_string(), "The server's SQLite database matches a fresh projection of the journal, every table, row for row.".to_string()),
|
||||
DiskCheck::Behind { applied, journal } => (true, "Database catching up".to_string(), format!("The database has applied {applied} of {journal} journal events; the server applies the rest right after writing them.")),
|
||||
DiskCheck::Missing => (true, "Database will rebuild".to_string(), "No database file yet; the server builds it from the journal when it starts.".to_string()),
|
||||
DiskCheck::Differs(why) => (false, "Database differs".to_string(), format!("{why}. The server rebuilds it from the journal (books.verify, or on its next start).")),
|
||||
DiskCheck::Unreadable(why) => (false, "Database unreadable".to_string(), why.clone()),
|
||||
};
|
||||
check(ui, ok, text, tip);
|
||||
let bad = b.integrity.file_failures.len();
|
||||
check(
|
||||
ui,
|
||||
bad == 0,
|
||||
if bad == 0 { format!("{} files verified", b.integrity.files_ok) } else { format!("{bad} files damaged") },
|
||||
"Every stored receipt and document re-hashed against its recorded SHA-256.".into(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -260,6 +260,43 @@ pub fn find(b: &Books) -> Vec<Attention> {
|
||||
}
|
||||
}
|
||||
|
||||
use ledger_store::DiskCheck;
|
||||
let verify_prompt = "In wanderbooks, run books.verify and tell me what it found.".to_string();
|
||||
match &b.integrity.disk {
|
||||
DiskCheck::Differs(why) => out.push(Attention {
|
||||
severity: Severity::Problem,
|
||||
title: "The database doesn't match the journal".into(),
|
||||
detail: format!("{why}. The journal is the record; the server rebuilds the database from it (books.verify, or on its next start)."),
|
||||
target: Some(Route::Journal),
|
||||
prompt: verify_prompt.clone(),
|
||||
}),
|
||||
DiskCheck::Unreadable(why) => out.push(Attention {
|
||||
severity: Severity::Problem,
|
||||
title: "Couldn't check the database".into(),
|
||||
detail: format!("{why}."),
|
||||
target: Some(Route::Journal),
|
||||
prompt: verify_prompt.clone(),
|
||||
}),
|
||||
_ => {}
|
||||
}
|
||||
if !b.integrity.file_failures.is_empty() {
|
||||
let ids: Vec<String> = b
|
||||
.integrity
|
||||
.file_failures
|
||||
.iter()
|
||||
.map(|(id, _)| id.to_string())
|
||||
.collect();
|
||||
out.push(Attention {
|
||||
severity: Severity::Problem,
|
||||
title: format!("{} damaged or missing", fmt::count(ids.len(), "stored file is", "stored files are")),
|
||||
detail: format!(
|
||||
"Attachment {}: the bytes on disk don't match their recorded hash. Restore them from a backup (the books dir's git history).",
|
||||
ids.join(", ")
|
||||
),
|
||||
target: Some(Route::Documents),
|
||||
prompt: verify_prompt,
|
||||
});
|
||||
}
|
||||
out.sort_by_key(|a| a.severity);
|
||||
out
|
||||
}
|
||||
@@ -85,6 +85,46 @@ pub struct Books {
|
||||
pub receipt_threshold: Decimal,
|
||||
pub attention: Vec<Attention>,
|
||||
pub kpis: Kpis,
|
||||
pub integrity: Integrity,
|
||||
}
|
||||
|
||||
/// Whether what's on disk is what the journal says.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Integrity {
|
||||
/// The writer's SQLite database against the journal.
|
||||
pub disk: ledger_store::DiskCheck,
|
||||
/// Stored files whose bytes match their hash.
|
||||
pub files_ok: u64,
|
||||
/// Files that are missing or damaged: (attachment id, why).
|
||||
pub file_failures: Vec<(u64, String)>,
|
||||
}
|
||||
|
||||
impl Integrity {
|
||||
/// Check the database and every file not already verified (by hash, so
|
||||
/// a file is read once however often the books change).
|
||||
pub fn check(snap: &Snapshot, verified: &mut HashSet<String>) -> Integrity {
|
||||
let store = ledger_store::BlobStore::open_read_only(&snap.data_dir().join("blobs"));
|
||||
let mut ok = 0;
|
||||
let mut failures = Vec::new();
|
||||
for a in snap.attachments(None).unwrap_or_default() {
|
||||
if verified.contains(&a.blob_sha) {
|
||||
ok += 1;
|
||||
continue;
|
||||
}
|
||||
match store.get(&a.blob_sha) {
|
||||
Ok(_) => {
|
||||
verified.insert(a.blob_sha.clone());
|
||||
ok += 1;
|
||||
}
|
||||
Err(e) => failures.push((a.id, e.to_string())),
|
||||
}
|
||||
}
|
||||
Integrity {
|
||||
disk: snap.check_disk(),
|
||||
files_ok: ok,
|
||||
file_failures: failures,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The headline numbers.
|
||||
@@ -113,6 +153,7 @@ impl Books {
|
||||
settings: BooksSettings,
|
||||
today: NaiveDate,
|
||||
prev_seq: Option<u64>,
|
||||
integrity: Integrity,
|
||||
) -> Result<Books, String> {
|
||||
let ledger = snap.ledger().clone();
|
||||
let attachments = snap.attachments(None).map_err(|e| e.to_string())?;
|
||||
@@ -268,6 +309,7 @@ impl Books {
|
||||
receipt_threshold,
|
||||
attention: Vec::new(),
|
||||
kpis: Kpis::default(),
|
||||
integrity,
|
||||
};
|
||||
books.kpis = books.compute_kpis();
|
||||
books.attention = crate::attention::find(&books);
|
||||
|
||||
@@ -5,11 +5,12 @@
|
||||
//! two. Filesystem events are the trigger; a slow poll backs them up in case
|
||||
//! an event is missed (network drives, odd editors).
|
||||
|
||||
use crate::books::Books;
|
||||
use crate::books::{Books, Integrity};
|
||||
use chrono::NaiveDate;
|
||||
use ledger_config::BooksSettings;
|
||||
use ledger_store::Snapshot;
|
||||
use notify::{RecursiveMode, Watcher};
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
|
||||
@@ -63,7 +64,8 @@ impl Loader {
|
||||
/// Load once, synchronously (tests and one-shot tools).
|
||||
pub fn load_once(source: &Source) -> Result<Books, String> {
|
||||
let snap = Snapshot::load(&source.data_dir).map_err(|e| e.to_string())?;
|
||||
Books::build(&snap, settings(source)?, today(source), None)
|
||||
let integrity = Integrity::check(&snap, &mut HashSet::new());
|
||||
Books::build(&snap, settings(source)?, today(source), None, integrity)
|
||||
}
|
||||
|
||||
fn today(source: &Source) -> NaiveDate {
|
||||
@@ -102,13 +104,22 @@ fn run(source: Source, ctx: egui::Context, tx: Sender<Update>) {
|
||||
}
|
||||
}
|
||||
};
|
||||
let build = |snap: &Snapshot, prev: Option<u64>| -> Update {
|
||||
match settings(&source).and_then(|s| Books::build(snap, s, today(&source), prev)) {
|
||||
let mut verified: HashSet<String> = HashSet::new();
|
||||
// Build the picture; also return the database check it was built with.
|
||||
let mut build = |snap: &Snapshot, prev: Option<u64>| -> (Update, ledger_store::DiskCheck) {
|
||||
let integrity = Integrity::check(snap, &mut verified);
|
||||
let disk = integrity.disk.clone();
|
||||
let update = match settings(&source)
|
||||
.and_then(|s| Books::build(snap, s, today(&source), prev, integrity))
|
||||
{
|
||||
Ok(b) => Update::Books(Arc::new(b)),
|
||||
Err(e) => Update::Error(e),
|
||||
}
|
||||
};
|
||||
(update, disk)
|
||||
};
|
||||
send(build(&snap, None));
|
||||
let (first, disk) = build(&snap, None);
|
||||
let mut last_disk = Some(disk);
|
||||
send(first);
|
||||
|
||||
let (ev_tx, ev_rx) = channel();
|
||||
let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
|
||||
@@ -149,10 +160,19 @@ fn run(source: Source, ctx: egui::Context, tx: Sender<Update>) {
|
||||
};
|
||||
let toml_now = mtime(&source.books_toml);
|
||||
let new_day = today(&source);
|
||||
if grew || toml_now != toml_mtime || new_day != day {
|
||||
// The writer journals first and updates its database right after:
|
||||
// a "behind" database usually catches up a moment later, so look
|
||||
// again rather than leave a stale warning up.
|
||||
let disk_changed = !grew
|
||||
&& last_disk
|
||||
.as_ref()
|
||||
.is_some_and(|d| *d != ledger_store::DiskCheck::Matches && snap.check_disk() != *d);
|
||||
if grew || disk_changed || toml_now != toml_mtime || new_day != day {
|
||||
toml_mtime = toml_now;
|
||||
day = new_day;
|
||||
send(build(&snap, grew.then_some(before)));
|
||||
let (update, disk) = build(&snap, grew.then_some(before));
|
||||
last_disk = Some(disk);
|
||||
send(update);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user