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:
swaitsandClaude Opus 5.5 committed 2026-10-12 17:05:05 -07:00
1 parent 714867ed58
commit 9694d2a89a
25 files changed
+808 -41

No files matched your search

+42
View File
@@ -143,3 +143,45 @@ fn walk(dir: &std::path::Path) -> Vec<(String, u64)> {
}
out
}
#[test]
fn a_snapshot_checks_the_writers_database_and_files() {
use ledger_store::DiskCheck;
let dir = test_dir("disk");
let mut store = Store::create(&dir, Currency::USD).unwrap();
let cash = store
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let equity = store
.add_account("3000", "Equity", AccountType::Equity, None, false)
.unwrap();
let e = post(&mut store, 1, cash, equity, 100);
store
.attach_receipt(Some(e), "r.png", "image/png", b"receipt-bytes", None)
.unwrap();
drop(store);
let snap = Snapshot::load(&dir).unwrap();
assert_eq!(snap.check_disk(), DiskCheck::Matches);
assert_eq!(snap.verify_blobs(), (1, vec![]));
// Someone edits the database behind the journal's back.
let conn = rusqlite::Connection::open(dir.join(ledger_store::DB_FILE)).unwrap();
conn.execute(
"UPDATE accounts SET name = 'Petty cash' WHERE code = '1000'",
[],
)
.unwrap();
drop(conn);
match snap.check_disk() {
DiskCheck::Differs(why) => assert!(why.contains("accounts"), "{why}"),
other => panic!("drift not seen: {other:?}"),
}
// A damaged file is caught too.
let blob = walk(&dir.join("blobs"))
.into_iter()
.map(|(p, _)| p)
.find(|p| !p.ends_with(".tmp"))
.unwrap();
std::fs::write(&blob, b"tampered").unwrap();
let (ok, bad) = snap.verify_blobs();
assert_eq!((ok, bad.len()), (0, 1));
}
+44
View File
@@ -683,3 +683,47 @@ fn vendor_1099_tags_replay_and_project() {
assert_eq!(form, "nec");
}
}
#[test]
fn the_whole_projection_is_checked_and_healed() {
let dir = test_dir("heal");
let mut store = build_books(&dir);
let e = store
.ledger()
.posted_entries()
.next()
.map(|e| e.id())
.unwrap();
store
.attach_receipt(Some(e), "r.png", "image/png", b"receipt", None)
.unwrap();
store.verify().unwrap();
drop(store);
// Drift no accounting report would notice: a receipt quietly unlinked.
let db = dir.join(ledger_store::DB_FILE);
let conn = rusqlite::Connection::open(&db).unwrap();
conn.execute("UPDATE attachments SET entry_id = NULL", [])
.unwrap();
drop(conn);
let mut store = Store::open(&dir).unwrap();
match store.verify() {
Err(StoreError::VerifyMismatch(why)) => assert!(why.contains("attachments"), "{why}"),
other => panic!("drift not caught: {other:?}"),
}
let report = store.verify_and_heal().unwrap();
assert!(report.healed.is_some(), "healed");
assert!(report.events > 0);
store.verify().unwrap();
assert_eq!(
store.list_attachments(Some(e)).unwrap().len(),
1,
"link restored"
);
let kept = std::fs::read_dir(&dir)
.unwrap()
.filter_map(|d| d.ok())
.any(|d| d.file_name().to_string_lossy().contains(".mismatch."));
assert!(kept, "the divergent database is kept for forensics");
// A healthy store reports nothing to heal.
assert_eq!(store.verify_and_heal().unwrap().healed, None);
}