viewer: proven read-only; its database check no longer creates SQLite files
check / check (push) Canceled after 0s
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:
1 parent
13a52c5849
commit
fe9625502f
3 files changed
+217
-5
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -229,10 +229,28 @@ impl Snapshot {
|
||||
if !path.exists() {
|
||||
return DiskCheck::Missing;
|
||||
}
|
||||
let mut conn = match Connection::open_with_flags(
|
||||
// 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
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user