From fe9625502f2229d03b833a8b345f407612d51339 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Wed, 14 Oct 2026 13:54:40 -0600 Subject: [PATCH] viewer: proven read-only; its database check no longer creates SQLite files tests/read_only.rs: with every file and folder of the books read-only on disk, the viewer loads, follows live, and renders every page (receipts included), and every file keeps its bytes, size, and mtime; on writable books nothing appears beside the database either. Found: with no writer running, the snapshot's read-only open of the WAL database had to create -wal/-shm (a write into the books) and failed on read-only books, showing a false "database unreadable". With no -wal file no writer has it open, so check_disk now reads it immutable. just mutants-gui sweeps only what the viewer must get right: the view model's numbers (books.rs) and their formatting (fmt.rs); 179 mutants. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX --- crates/ledger-gui/tests/read_only.rs | 177 +++++++++++++++++++++++++++ crates/ledger-store/src/snapshot.rs | 41 ++++++- justfile | 8 +- 3 files changed, 219 insertions(+), 7 deletions(-) create mode 100644 crates/ledger-gui/tests/read_only.rs diff --git a/crates/ledger-gui/tests/read_only.rs b/crates/ledger-gui/tests/read_only.rs new file mode 100644 index 0000000..ce31337 --- /dev/null +++ b/crates/ledger-gui/tests/read_only.rs @@ -0,0 +1,177 @@ +//! The viewer is read-only: it never writes into the books. +//! +//! ```text +//! GOAL: Stephen, Kat, and the CPA can open the books in the viewer +//! without any chance of changing them. +//! SOURCE: DECISION (AGENTS.md: "read-only and live ... It never writes; +//! changes go through you"). +//! INVARIANTS: +//! 1. With every file and folder of the books made read-only on disk, +//! the viewer still loads, follows live, and renders every page +//! (receipts included), and reports the books whole. +//! 2. Afterwards every file holds the same bytes, size, and +//! modification time, and no file has appeared or gone; on writable +//! books too (no SQLite -wal or -shm created beside the database). +//! ``` + +use egui_kittest::Harness; +use ledger_gui::data::{self, Loader, Update}; +use ledger_gui::nav::{ReportKind, Route}; +use ledger_gui::{App, Source}; +use std::collections::BTreeMap; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime}; + +type Tree = BTreeMap, u64, SystemTime)>; + +fn tree(root: &Path) -> Tree { + fn walk(root: &Path, d: &Path, out: &mut Tree) { + for e in std::fs::read_dir(d).unwrap() { + let p = e.unwrap().path(); + let m = std::fs::symlink_metadata(&p).unwrap(); + if m.is_dir() { + walk(root, &p, out); + } else { + out.insert( + p.strip_prefix(root).unwrap().to_path_buf(), + (std::fs::read(&p).unwrap(), m.len(), m.modified().unwrap()), + ); + } + } + } + let mut out = BTreeMap::new(); + walk(root, root, &mut out); + out +} + +/// Every file 0444 and folder 0555 (`read_only`), or back to 0644/0755. +fn set_read_only(root: &Path, read_only: bool) { + fn walk(p: &Path, ro: bool) { + let m = std::fs::symlink_metadata(p).unwrap(); + if m.is_dir() { + if !ro { + std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + for e in std::fs::read_dir(p).unwrap() { + walk(&e.unwrap().path(), ro); + } + if ro { + std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o555)).unwrap(); + } + } else { + let mode = if ro { 0o444 } else { 0o644 }; + std::fs::set_permissions(p, std::fs::Permissions::from_mode(mode)).unwrap(); + } + } + walk(root, read_only); +} + +#[test] +fn the_viewer_never_writes_into_the_books() { + let dir = std::env::temp_dir().join(format!("ledger-gui-ro-{}", std::process::id())); + if dir.exists() { + set_read_only(&dir, false); + std::fs::remove_dir_all(&dir).unwrap(); + } + let today = chrono::NaiveDate::from_ymd_opt(2026, 10, 12).unwrap(); + ledger_gui::demo::build(&dir, today).unwrap(); + let source = Source { + data_dir: dir.join("books-data"), + books_toml: Some(dir.join("wanderbooks.toml")), + today: Some(today), + }; + set_read_only(&dir, true); + let before = tree(&dir); + + // Load once; the books are whole (the database matches the journal). + let books = data::load_once(&source).expect("loads from read-only books"); + assert!( + matches!(books.integrity.disk, ledger_store::DiskCheck::Matches), + "read-only books reported as {:?}", + books.integrity.disk + ); + assert!(books.integrity.file_failures.is_empty()); + let entry = books.entries.last().map(|e| e.id).unwrap(); + let cash = books.cash_accounts[0]; + let doc = books.attachments.last().map(|a| a.id).unwrap(); + let event = books.seq(); + drop(books); + + // Follow live for a moment. + let loader = Loader::spawn(source.clone(), egui::Context::default()); + let deadline = std::time::Instant::now() + Duration::from_secs(20); + let mut live = false; + while std::time::Instant::now() < deadline { + match loader.wait(Duration::from_millis(200)) { + Some(Update::Books(_)) => { + live = true; + break; + } + Some(Update::Error(e)) => panic!("live loader on read-only books: {e}"), + _ => {} + } + } + assert!(live, "the live loader never delivered the books"); + + // Render every page. + let routes = [ + Route::Overview, + Route::Transactions, + Route::Entry(entry), + Route::Cash, + Route::Profit, + Route::Reports(ReportKind::ProfitAndLoss), + Route::Reports(ReportKind::BalanceSheet), + Route::Reports(ReportKind::CashFlow), + Route::Reports(ReportKind::TrialBalance), + Route::Reports(ReportKind::GeneralLedger), + Route::Reports(ReportKind::TaxSummary), + Route::Reports(ReportKind::Vendor1099), + Route::Reports(ReportKind::ArAging), + Route::Accounts, + Route::Account(cash), + Route::Document(doc), + Route::JournalEvent(event), + Route::Documents, + Route::Company, + Route::Journal, + ]; + for route in routes { + let books = data::load_once(&source).unwrap(); + let mut harness = Harness::builder() + .with_size(egui::vec2(1280.0, 800.0)) + .build_eframe(|cc| App::with_books(&cc.egui_ctx, books, false, route.clone())); + harness.run_steps(3); + let ctx = harness.ctx.clone(); + harness.state_mut().settle(&ctx); + harness.run_steps(2); + } + + let after = tree(&dir); + set_read_only(&dir, false); + let changed: Vec<_> = before + .keys() + .chain(after.keys()) + .filter(|k| before.get(*k) != after.get(*k)) + .collect(); + assert!(changed.is_empty(), "the viewer changed {changed:?}"); + + // On writable books too: loading and checking creates nothing (no + // SQLite -wal or -shm beside the database). + let before = tree(&dir); + let books = data::load_once(&source).unwrap(); + assert!(matches!( + books.integrity.disk, + ledger_store::DiskCheck::Matches + )); + drop(books); + let after = tree(&dir); + assert_eq!( + before.keys().collect::>(), + after.keys().collect::>(), + "the viewer created or removed files in writable books" + ); + assert!(before == after, "the viewer changed writable books"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ledger-store/src/snapshot.rs b/crates/ledger-store/src/snapshot.rs index d852c1d..def0f31 100644 --- a/crates/ledger-store/src/snapshot.rs +++ b/crates/ledger-store/src/snapshot.rs @@ -229,10 +229,28 @@ impl Snapshot { if !path.exists() { return DiskCheck::Missing; } - let mut conn = match Connection::open_with_flags( - &path, - OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, - ) { + // A WAL reader needs the -wal and -shm files, and creates them if + // they're missing: a write into the books, and a failure when the + // books are read-only to this user. With no -wal file no writer + // has the database open (SQLite deletes it when the last one + // closes), so it can't change under us: read it as an immutable + // file, creating nothing (found by the viewer's read-only test, + // 2026-10-14). + let live = path.with_extension("db-wal").exists(); + let opened = if live { + Connection::open_with_flags( + &path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + } else { + Connection::open_with_flags( + immutable_uri(&path), + OpenFlags::SQLITE_OPEN_READ_ONLY + | OpenFlags::SQLITE_OPEN_NO_MUTEX + | OpenFlags::SQLITE_OPEN_URI, + ) + }; + let mut conn = match opened { Ok(c) => c, Err(e) => return DiskCheck::Unreadable(e.to_string()), }; @@ -286,3 +304,18 @@ impl Snapshot { read_attachment_bytes(&self.conn, &self.blobs, attachment_id) } } + +/// `file:` URI for `path`, read-only and immutable (sqlite.org/uri.html): +/// every byte outside the unreserved set is percent-encoded. +fn immutable_uri(path: &Path) -> String { + let mut out = String::from("file:"); + for b in path.to_string_lossy().bytes() { + if b.is_ascii_alphanumeric() || b"/-_.~".contains(&b) { + out.push(b as char); + } else { + out.push_str(&format!("%{b:02X}")); + } + } + out.push_str("?mode=ro&immutable=1"); + out +} diff --git a/justfile b/justfile index 5af77de..6fd2e46 100644 --- a/justfile +++ b/justfile @@ -94,11 +94,13 @@ mutants-mcp: mkdir -p {{mutants_tmp}} MCP_CASES=16 PROPTEST_MAX_SHRINK_ITERS=0 TMPDIR={{mutants_tmp}} cargo mutants -p ledger-mcp --test-package ledger-mcp -j 4 --timeout 600 -o mutants-mcp -# Mutation testing of the viewer's logic (formatting, file names, history, -# search, ticks, the view model, the attention list; not the painting). +# Mutation testing of the viewer where it matters: the numbers it shows +# (the view model) and how it writes them (formatting). Its read-only +# promise has its own test (tests/read_only.rs); search, history, ticks, +# and the attention list are spec-tested, not swept. mutants-gui: mkdir -p {{mutants_tmp}} - PROPTEST_MAX_SHRINK_ITERS=0 TMPDIR={{mutants_tmp}} cargo mutants -p ledger-gui --test-package ledger-gui -j 4 --timeout 60 -o mutants-gui -f crates/ledger-gui/src/fmt.rs -f crates/ledger-gui/src/export.rs -f crates/ledger-gui/src/nav.rs -f crates/ledger-gui/src/attention.rs -f crates/ledger-gui/src/books.rs -f crates/ledger-gui/src/palette.rs -f crates/ledger-gui/src/charts.rs + PROPTEST_MAX_SHRINK_ITERS=0 TMPDIR={{mutants_tmp}} cargo mutants -p ledger-gui --test-package ledger-gui -j 4 --timeout 180 -o mutants-gui -f crates/ledger-gui/src/books.rs -f crates/ledger-gui/src/fmt.rs # Benchmarks of the core's hot paths (no budget gates them yet) bench: