Files
wanderbooks/crates/ledger-gui/tests/read_only.rs
T
swaitsandClaude Fable 5.1 bd6448532e feat(host): the lease and MCP channels as named pipes with the SID check, and the host, bridge and commands on Windows (P11c)
Windows steps 3 of D-62. Every `cfg(unix)` gate on the host, the bridge,
the opener, the books server and the command line is gone: they build on
every OS, and the Unix behaviour is unchanged (the existing over-the-wire
suites pass on Linux).

`ledger_mcp::ipc` is the one seam for the channels. Unix: the same 0600
sockets as before, peer uid unchanged (the peer functions moved here).
Windows: a named pipe `\\.\pipe\wanderbooks-<hash of the root's run/>-<name>`
made with a DACL of one allow entry for this account's SID, remote
clients refused, the first instance made once (a second server is
refused, as a held lock refuses it on Unix), and 16 idle instances kept
waiting so a client never finds all busy while the server makes the next.
Where Unix reads `SO_PEERCRED`, each end of a pipe takes the other's
process id (`GetNamedPipeClientProcessId`/`...ServerProcessId`) and
compares that process's token user with its own SID; any doubt is "not
us". Clients open with SECURITY_IDENTIFICATION so a pipe server cannot
act as the client. A blocking client with a read limit is polled with
PeekNamedPipe (a pipe read cannot be cut short), and a pipe that has
gone reads as the end of the stream, as a closed socket does. Stop
signals are Ctrl+C, Ctrl+Break, console close and shutdown where Unix has
SIGTERM, SIGINT and SIGHUP.

The host starts detached (DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP,
leaving the opener's job when the job lets it: an agent's job must not
take the host down, as an app's systemd scope must not on Linux) with a
fixed environment; `close_range` and `seccomp` stay Linux-only (D-32). A
replaced program is told by size and last write. Git for Windows is found
and a quiet git is stopped with its tree (`taskkill /T`). The test
harness of ledger-cli finds its root by `default_root`, cleans a child's
environment per OS and reaches the server over its pipe; the bridge's
scripted-server tests stay Unix-only.

No new dependency (tokio's named pipes and reqwest's `windows_named_pipe`
were already in the build); `windows-sys` stays in ledger-store.

Tests: ledger-mcp `ipc::tests` (four on every OS, one Windows only),
ledger-host `tests/windows_pipe.rs::a_second_user_cannot_open_the_pipe`,
Windows variants of the host and start tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 15:02:12 -06:00

246 lines
8.5 KiB
Rust

//! The viewer is read-only: it never writes into the books.
//!
//! ```text
//! GOAL: Tobin, Mo, 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;
#[cfg(unix)]
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.
/// On Windows a file gets its read-only attribute (which stops a write
/// to it) and a folder is left as it is: a folder can't be made
/// read-only there.
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() {
#[cfg(unix)]
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);
}
#[cfg(unix)]
if ro {
std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o555)).unwrap();
}
} else {
#[cfg(unix)]
{
let mode = if ro { 0o444 } else { 0o644 };
std::fs::set_permissions(p, std::fs::Permissions::from_mode(mode)).unwrap();
}
#[cfg(windows)]
{
let mut permissions = m.permissions();
permissions.set_readonly(ro);
std::fs::set_permissions(p, permissions).unwrap();
}
}
}
walk(root, read_only);
}
/// Makes the books writable again and removes them when dropped, so a
/// run that passes or fails never leaves a folder behind, let alone one no
/// one can delete.
struct Writable(PathBuf);
impl Drop for Writable {
fn drop(&mut self) {
if self.0.exists() {
set_read_only(&self.0, false);
let _ = std::fs::remove_dir_all(&self.0);
}
}
}
/// Folders left read-only by a killed run (no destructor ran): made
/// writable and removed once their process is gone.
fn sweep_leftovers() {
let Ok(entries) = std::fs::read_dir(std::env::temp_dir()) else {
return;
};
for e in entries.flatten() {
let name = e.file_name().to_string_lossy().to_string();
let Some(pid) = name.strip_prefix("ledger-gui-ro-") else {
continue;
};
if Path::new("/proc").join(pid).exists() {
continue; // still running (or the pid was reused): leave it
}
set_read_only(&e.path(), false);
let _ = std::fs::remove_dir_all(e.path());
}
}
#[test]
fn the_viewer_never_writes_into_the_books() {
sweep_leftovers();
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 _restore = Writable(dir.clone());
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"),
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();
let asset = books
.ledger
.registered_assets()
.next()
.map(|a| a.id)
.unwrap();
let party = books.ledger.parties().next().map(|p| p.id).unwrap();
// Every project's page too (visiting_project_pages_changes_nothing).
let projects: Vec<u32> = books.ledger.list_classes().iter().map(|c| c.id).collect();
assert!(!projects.is_empty(), "the demo has projects");
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::Reports(ReportKind::ApAging),
Route::Reports(ReportKind::MembersEquity),
Route::Reports(ReportKind::FixedAssetRegister),
Route::Accounts,
Route::Account(cash),
Route::Assets,
Route::Asset(asset),
Route::Parties,
Route::Party(party),
Route::Projects,
Route::Document(doc),
Route::JournalEvent(event),
Route::Documents,
Route::Company,
Route::Journal,
];
let routes = routes
.into_iter()
.chain(projects.iter().map(|&c| Route::Project(c)));
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");
}