viewer: proven read-only; its database check no longer creates SQLite files
check / check (push) Canceled after 0s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-14 13:54:40 -06:00
1 parent 13a52c5849
commit fe9625502f
3 files changed
+219 -7

No files matched your search

+177
View File
@@ -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<PathBuf, (Vec<u8>, 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::<Vec<_>>(),
after.keys().collect::<Vec<_>>(),
"the viewer created or removed files in writable books"
);
assert!(before == after, "the viewer changed writable books");
let _ = std::fs::remove_dir_all(&dir);
}
+37 -4
View File
@@ -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
}
+5 -3
View File
@@ -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: