Compare commits

..
5 Commits
Author SHA1 Message Date
swaits 48062b89f0 test(fuzz): the operation sequence gains documents, settlements and drafts (P12)
check / check (push) Waiting to run
portable-macos / portable (macos-latest) (push) Waiting to run
portable-windows / portable (windows-latest) (push) Waiting to run
two-user / two-user (push) Waiting to run
The sequence only knew accounts, lines, periods and closes, but that is no
longer where money moves: invoices, bills, their payments, write-offs and
voids are now first class, and the draft-state commands decide whether an
entry exists at all. Op gains each of them, on the same Ledger the store
wraps, with random parties, dates and amounts (including the raw-decimal
extremes).

The invariant after every sequence gains B-38: each control account equals
its documents as of three dates, and a control check too large to compute is
a refusal, never a difference. That is the identity that would catch a
payment tagged to the wrong document, or one that went unmatched, which the
trial balance alone cannot see.
2026-11-05 08:36:15 -07:00
swaits 7002e89efd test(fuzz): arbitrary bytes into the HTTP door and the upload path (P12)
P2 promised these two targets and never added them. `fuzz_http_mcp_request`
feeds a method, a path, headers (token present, absent, wrong, raw) and a
JSON-RPC body drawn from the real method and tool names into the in-process
router. Whatever arrives, the door answers: no token live for the book is
refused 4xx, never run (INVARIANT 8), and a live token never makes the door
fail (no 5xx on the MCP route). Whether a token is live is asked of the door
itself (`FuzzDoor::carries_a_token`), so a token smuggled in an extra header
is judged by the real rule rather than by the target's guess.

`fuzz_upload_put` declares a size and a hash and sends bytes: taken only
when both are exactly what was declared, from the token the grant names,
and only once (INVARIANT 9); a 200 for anything else is the bug.

Both join the weekly matrix in scheduled.yml; fuzz/Cargo.lock gains
ledger-mcp's dependency closure because the targets drive its router.
2026-11-05 08:36:15 -07:00
swaits 5e474cd1ac feat(mcp): the books door served in-process for fuzzing (P12)
The local socket and the tunnel already mount the same four routes; naming
that table `router` gives the fuzz targets the very door a client reaches,
not a copy of it. `local/fuzz_door` (behind the `fuzzing` feature, off in
every shipped build) builds a book, two live tokens and that router, and
answers one request through `tower::oneshot`: no socket, no port, no clock.

The target's oracle is then the code that enforces it: INVARIANT 8 (a book
only behind a live bearer token) is checked by asking `Books::bearer`, not
by a second model of the rule that could drift from it.
2026-11-05 08:36:15 -07:00
swaitsandClaude Fable 5.1 4b0196e416 docs(testing): P12 checklists, one per crate (P12 step 1)
The For P12 paragraphs, each left where its unit wrote it with a pointer,
consolidated into one mutation checklist per crate, each item keeping its
origin unit in brackets, so the run walks a crate at a time.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-11-05 08:36:15 -07:00
swaitsandClaude Fable 5.1 cecff55db6 test(just): mutation recipes for host, package, vault and cli (P12 step 1)
Each recipe mutates one crate and runs its own tests plus ledger-cli's
end-to-end suites without the viewer, as mutants-mcp does, so a mutant
in a crate whose behavior is reached only through the one executable is
still caught. Local runs only (D-59): no shard goes into scheduled.yml.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-11-05 08:36:15 -07:00
27 changed files with 2383 additions and 998 deletions

No files matched your search

+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
strategy:
fail-fast: false
matrix:
target: [fuzz_journal_line, fuzz_entry_validate, fuzz_op_sequence, fuzz_core_serde, fuzz_store_open, fuzz_store_replay, fuzz_settings_replay, fuzz_import_csv, fuzz_config_parse, fuzz_extract_pdf, fuzz_extract_xlsx]
target: [fuzz_journal_line, fuzz_entry_validate, fuzz_op_sequence, fuzz_core_serde, fuzz_store_open, fuzz_store_replay, fuzz_settings_replay, fuzz_import_csv, fuzz_config_parse, fuzz_extract_pdf, fuzz_extract_xlsx, fuzz_http_mcp_request, fuzz_upload_put]
steps:
- uses: actions/checkout@v4
- uses: jdx/mise-action@v2
-15
View File
@@ -172,21 +172,6 @@ impl ledger_gui::owner::Owner for ViewerOwner {
fn upgrade_format(&self, book: &str, passphrase: &str) -> Result<Vec<String>, String> {
ledger_host::commands::upgrade_format(&self.0, book, passphrase)
}
fn update_waiting(&self) -> Option<ledger_gui::owner::UpdateWaiting> {
ledger_host::commands::update_waiting(&self.0).map(|u| ledger_gui::owner::UpdateWaiting {
version: u.version,
staged_at: u.staged_at,
})
}
fn check_for_updates(&self) -> Result<Vec<String>, String> {
ledger_host::commands::check_for_updates(&self.0).map(|l| vec![l])
}
fn apply_update(&self) -> Result<Vec<String>, String> {
ledger_host::commands::apply_update(&self.0).map(|l| vec![l])
}
}
/// `wanderbooks gui`: the viewer, in this process. For books of the data
-133
View File
@@ -1,133 +0,0 @@
//! A macOS app run translocated refuses to connect an agent (P11a step 6):
//! Gatekeeper runs a quarantined download from a read-only copy under
//! `/private/var/folders/…/AppTranslocation/…`, and that path goes away
//! with the app; an agent pointed at it would lose the books. The owner
//! moves the app out of Downloads first.
//!
//! The check is on the path the program would give the agent
//! (`ledger_host::start::this_executable`), so a test can stand in for
//! macOS on any Unix: `APPIMAGE` names a file there, and a path with an
//! `AppTranslocation` part is refused just as the real one is.
#![cfg(unix)]
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
const KNOBS: &str =
"close_after=2500,backoff=50,exe_poll=100,init_wait=5000,queue_wait=30000,backup_quiet=300";
struct Home {
dir: tempfile::TempDir,
}
impl Home {
fn new() -> Self {
let dir = tempfile::Builder::new()
.prefix("wb-translocation-")
.tempdir()
.unwrap();
let out = Command::new(env!("CARGO_BIN_EXE_wanderbooks"))
.args(["setup", "--home"])
.arg(dir.path())
.env_remove("XDG_DATA_HOME")
.output()
.unwrap();
assert!(out.status.success(), "{out:?}");
Self { dir }
}
fn root(&self) -> PathBuf {
ledger_store::root::default_root(self.dir.path())
}
/// The program with a root of its own and `PATH` holding the stand-in
/// agent. Nothing here touches the account's real data root.
fn cmd(&self, mode: &str, path: &Path) -> Command {
let mut c = Command::new(env!("CARGO_BIN_EXE_wanderbooks"));
c.arg(mode)
.arg("--root")
.arg(self.root())
.args(["--test-knobs", KNOBS])
.env_clear()
.env("PATH", format!("{}:/usr/bin:/bin", path.display()))
.env("HOME", self.dir.path())
.stdin(Stdio::null());
c
}
}
/// A stand-in `claude` that leaves a mark when it is run, so the test can
/// say the agent never was.
fn fake_agent(home: &Home) -> PathBuf {
use std::os::unix::fs::PermissionsExt;
let bin = home.dir.path().join("agent-bin");
std::fs::create_dir_all(&bin).unwrap();
let script = bin.join("claude");
std::fs::write(
&script,
format!(
"#!/bin/sh\ntouch '{}'\nexit 0\n",
home.dir.path().join("agent-ran").display()
),
)
.unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
bin
}
#[test]
fn translocated_app_refuses_to_connect() {
let home = Home::new();
let out = home
.cmd("books", Path::new("/nonexistent"))
.args(["create", "beta"])
.output()
.unwrap();
assert!(out.status.success(), "{out:?}");
let bin = fake_agent(&home);
// What macOS hands the program from Gatekeeper's copy: a path under
// AppTranslocation. A bare file is enough (this_executable only asks
// that it is one).
let translocated = home.dir.path().join(
"private/var/folders/x/AppTranslocation/uuid/d/Wanderbooks.app/Contents/MacOS/wanderbooks",
);
std::fs::create_dir_all(translocated.parent().unwrap()).unwrap();
std::fs::write(&translocated, b"").unwrap();
let out = home
.cmd("connect", &bin)
.env("APPIMAGE", &translocated)
.args(["claude", "--book", "beta"])
.output()
.unwrap();
assert!(
!out.status.success(),
"a translocated app connected an agent"
);
let said = String::from_utf8_lossy(&out.stderr);
assert!(said.contains("move wanderbooks out of Downloads"), "{said}");
// Refused before anything was done: no agent ran, no books home was
// made for a path that will vanish.
assert!(!home.dir.path().join("agent-ran").exists(), "the agent ran");
assert!(
!home.dir.path().join("Documents/Wanderbooks/beta").exists(),
"a books home was made"
);
// The same program at an ordinary path connects: it is the
// translocation, not the agent or the books, that was refused.
let ordinary = home.dir.path().join("Wanderbooks.AppImage");
std::fs::write(&ordinary, b"").unwrap();
let out = home
.cmd("connect", &bin)
.env("APPIMAGE", &ordinary)
.args(["claude", "--book", "beta"])
.output()
.unwrap();
assert!(out.status.success(), "{out:?}");
assert!(
home.dir.path().join("agent-ran").exists(),
"the agent wasn't run"
);
}
-134
View File
@@ -1040,137 +1040,3 @@ fn a_staged_update_that_was_tampered_with_is_dropped_not_applied() {
"the tampered copy was kept"
);
}
/// What the owner's app does when the owner accepts: one `apply_update`
/// on the host's lease socket (the viewer's `Owner::apply_update`, scripted).
fn owner_accepts(home: &Home) -> Result<Option<ledger_host::lease::HostMsg>, String> {
ledger_host::opener::ask_once_within(
&home.root().join("run"),
ledger_host::lease::Ask::Intent(ledger_host::lease::Intent::ApplyUpdate),
Duration::from_secs(90),
)
}
#[test]
fn books_start_says_an_update_waits() {
let home = Home::new("start-says");
home.create("acme");
let release = Release::new();
let next = newer(&home, "start-says");
release.publish("9.0.0", &next, |_| {});
let mut agent = Agent::start(&home, "acme");
let quiet = agent.call("books.start", json!({})).unwrap();
assert!(quiet.get("update_waiting").is_none(), "{quiet}");
name_feed(&home, &release);
// The host's look is once a day (a test knob makes it soon); a mark
// older than that lets the next poll look now.
let _ = std::fs::remove_file(home.updates().join("checked"));
assert!(
wait_for(30, || staged_version(&home).as_deref() == Some("9.0.0")),
"nothing was staged"
);
let start = agent.call("books.start", json!({})).unwrap();
assert_eq!(start["update_waiting"]["version"], "9.0.0", "{start}");
assert!(
start["update_waiting"]["staged_at"]
.as_str()
.unwrap()
.ends_with('Z')
);
assert!(
start["update_waiting"]["how"]
.as_str()
.unwrap()
.contains("only the owner"),
"{start}"
);
// An agent is told to tell the human, and is given no way to accept.
let steps = start["next_steps"].as_array().unwrap();
assert!(
steps
.iter()
.any(|s| s["why"].as_str().unwrap_or_default().contains("9.0.0")),
"{steps:?}"
);
let text = start.to_string();
assert!(
!text.contains(&home.dir.path().display().to_string()),
"a path in books.start"
);
}
#[test]
fn the_owner_accepts_a_staged_update_in_the_app() {
let home = Home::new("accept");
home.create("acme");
let mut agent = Agent::start(&home, "acme");
agent.add_account("4000").unwrap();
let host_before = wait_host(&home);
// Nothing staged: refused, and nothing changes.
match owner_accepts(&home) {
Ok(Some(ledger_host::lease::HostMsg::Refused(_))) => {}
other => panic!("accepting nothing: {other:?}"),
}
let release = Release::new();
let next = newer(&home, "accept");
release.publish("9.0.0", &next, |_| {});
name_feed(&home, &release);
let _ = std::fs::remove_file(home.updates().join("checked"));
assert!(
wait_for(30, || staged_version(&home).as_deref() == Some("9.0.0")),
"nothing was staged"
);
let old = home.program();
assert_eq!(sha256(&home.program()), sha256(&old));
// The owner accepts, in the app: the answer says it is installed.
match owner_accepts(&home) {
Ok(Some(ledger_host::lease::HostMsg::Done { detail })) => {
assert!(detail.unwrap_or_default().contains("9.0.0"));
}
other => panic!("accepting: {other:?}"),
}
assert_eq!(sha256(&home.program()), sha256(&next));
// Writes all through the restart onto it: none lost, none made twice,
// the session carries on with no reconnect.
let mut written = vec!["4000".to_string()];
let deadline = Instant::now() + Duration::from_secs(90);
let mut k = 0u32;
loop {
assert!(Instant::now() < deadline, "the host never restarted");
let code = format!("{}", 5000 + k);
k += 1;
let r = agent.add_account(&code);
assert!(r.is_ok(), "a write across the accept: {r:?}");
written.push(code);
if home.pids("host").len() == 1 && home.pids("host")[0] != host_before {
break;
}
}
let host = wait_host(&home);
assert!(wait_for(20, || !home.pids("server").is_empty()));
let exe = |pid: u32| std::fs::read_link(format!("/proc/{pid}/exe")).unwrap();
assert!(!exe(host).to_string_lossy().contains("(deleted)"));
assert_eq!(
sha256(&std::fs::read(home.updates().join("previous/wanderbooks")).unwrap()),
sha256(&old),
"the old program is kept for rollback"
);
agent.add_account("4999").unwrap();
written.push("4999".into());
let chart = agent.call("books.chart", json!({})).unwrap();
let mut in_books: Vec<String> = chart["accounts"]
.as_array()
.unwrap()
.iter()
.filter_map(|a| a["code"].as_str().map(str::to_string))
.filter(|c| c.starts_with('4') || c.starts_with('5'))
.collect();
in_books.sort();
written.sort();
assert_eq!(in_books, written);
}
+4 -113
View File
@@ -132,16 +132,6 @@ pub struct RestorePlan {
pub lines: Vec<String>,
}
/// An update the host has downloaded and checked, waiting for the owner to
/// accept it (P11a step 4).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UpdateWaiting {
/// Its version.
pub version: String,
/// When it was staged (UTC, RFC 3339).
pub staged_at: String,
}
/// The owner's actions, done by the program the viewer runs in (ledger-cli
/// over the books server and the agents' own commands). Each blocks: the
/// menu calls it off the UI thread.
@@ -182,21 +172,6 @@ pub trait Owner: Send + Sync {
}
/// Hold the books `book` open while the viewer runs (the viewer's lease).
fn hold(&self, _book: &str) {}
/// The update waiting for the owner's accept, if one is (read from the
/// data root; cheap, called every few seconds).
fn update_waiting(&self) -> Option<UpdateWaiting> {
None
}
/// "Check for updates": look at the release feed now and stage a newer
/// version if there is one. What came of it, in plain words.
fn check_for_updates(&self) -> Result<Vec<String>, String> {
Err("not here".into())
}
/// Accept the waiting update: the books' host installs it and restarts
/// onto it when no call is running, the books staying open.
fn apply_update(&self) -> Result<Vec<String>, String> {
Err("not here".into())
}
/// How to run `agent` in the books home of `book` (made if need be): the
/// dock starts it on a terminal of its own (D-27).
fn agent_start(&self, _agent: Agent, _book: &str) -> Result<AgentStart, String> {
@@ -316,14 +291,8 @@ pub struct OwnerMenu {
/// The books whose format upgrade was asked for since the viewer
/// opened them (INVARIANT 5: once each open).
asked_upgrade: Option<String>,
/// The update waiting, as last read, and when (re-read every few
/// seconds and after any job).
update: Option<(std::time::Instant, Option<UpdateWaiting>)>,
}
/// How long a reading of the waiting update is kept.
const UPDATE_LOOK: std::time::Duration = std::time::Duration::from_secs(5);
impl OwnerMenu {
/// The id of the books shown, if they are books of a data root: found
/// once for each load.
@@ -335,73 +304,15 @@ impl OwnerMenu {
self.book.as_ref().and_then(|(_, id)| id.clone())
}
/// The update waiting, if any (read now and then, not every frame).
fn update_waiting(
&mut self,
ctx: &egui::Context,
owner: &Arc<dyn Owner>,
) -> Option<UpdateWaiting> {
if self
.update
.as_ref()
.is_none_or(|(at, _)| at.elapsed() > UPDATE_LOOK)
{
self.update = Some((std::time::Instant::now(), owner.update_waiting()));
}
ctx.request_repaint_after(UPDATE_LOOK);
self.update.as_ref().and_then(|(_, u)| u.clone())
}
/// The standing notices above the page: an update waiting for the
/// owner's accept, and a format upgrade the books wait for, whose
/// go-ahead is asked for once each open (INVARIANT 5).
/// The standing notice of a format upgrade the books wait for, above
/// the page, and the go-ahead asked for once each open (INVARIANT 5).
pub fn notice(&mut self, ui: &mut Ui, books: &Arc<Books>) {
let Some(owner) = owner() else {
if owner().is_none() {
return;
};
}
let Some(book) = self.book_of(books) else {
return;
};
if let Some(update) = self.update_waiting(ui.ctx(), &owner) {
let p = crate::theme::pal(ui.ctx());
let mut start: Option<Box<dyn FnOnce() -> Outcome + Send>> = None;
egui::Frame::new()
.fill(p.warning_soft)
.inner_margin(egui::Margin::symmetric(20, 10))
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal_wrapped(|ui| {
ui.label(
RichText::new(format!(
"{} Version {} of wanderbooks is ready.",
icon::ARROW_FAT_UP,
update.version
))
.font(crate::theme::semibold(13.0))
.color(p.warning),
);
ui.label(
RichText::new(
"It is downloaded and checked. The books stay open while it starts, and no work in progress is cut off.",
)
.font(crate::theme::regular(13.0))
.color(p.text),
);
let free = self.job.is_none();
if ui
.add_enabled(free, egui::Button::new("Update now"))
.on_hover_text("Start using the new version")
.clicked()
{
let o = owner.clone();
start = Some(Box::new(move || o.apply_update()));
}
});
});
if let Some(work) = start {
self.start(ui.ctx(), "Update".into(), work);
}
}
let Some(upgrade) = books.format_upgrade.clone() else {
return;
};
@@ -467,7 +378,6 @@ impl OwnerMenu {
{
self.done = Some((what.clone(), done));
self.job = None;
self.update = None;
}
let text = if compact {
RichText::new(icon::BOOKS).size(16.0).color(p.muted)
@@ -481,7 +391,6 @@ impl OwnerMenu {
button = button.min_size(vec2(32.0, ui.spacing().interact_size.y + 4.0));
}
let busy = self.job.is_some();
let waiting = self.update_waiting(ui.ctx(), &owner);
let mut start: Option<(String, Box<dyn FnOnce() -> Outcome + Send>)> = None;
let response = ui
.add_enabled_ui(!busy, |ui| {
@@ -540,24 +449,6 @@ impl OwnerMenu {
}
}
ui.separator();
if ui
.button(format!("{} Check for updates", icon::ARROWS_CLOCKWISE))
.on_hover_text("Look for a newer version of wanderbooks now")
.clicked()
{
let o = owner.clone();
start = Some(("Check for updates".into(), Box::new(move || o.check_for_updates())));
}
if let Some(u) = &waiting
&& ui
.button(format!("{} Update to {}", icon::ARROW_FAT_UP, u.version))
.on_hover_text("Start using the version that is downloaded and checked: the books stay open")
.clicked()
{
let o = owner.clone();
start = Some(("Update".into(), Box::new(move || o.apply_update())));
}
ui.separator();
if ui
.button(format!("{} Close the books", icon::POWER))
.on_hover_text("Close the books now: agents' sessions and every other holder let go")
-199
View File
@@ -1,199 +0,0 @@
//! The viewer's update notice and its "Check for updates" menu item
//! (owner.rs, P11a step 4): a staged update the owner reports shows a
//! standing notice with an "Update now" button; the menu can look for one;
//! accepting hands the work to the owner, off the UI thread.
use chrono::NaiveDate;
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use ledger_core::Currency;
use ledger_gui::App;
use ledger_gui::books::Books;
use ledger_gui::data::{self, Source};
use ledger_gui::nav::Route;
use ledger_gui::owner::{Owner, RestorePlan, UpdateWaiting};
use ledger_store::Store;
use std::path::PathBuf;
use std::sync::{Arc, Mutex, OnceLock};
/// An owner with an update that waits once it has been "found".
#[derive(Default)]
struct Fake {
asked: Mutex<Vec<String>>,
staged: Mutex<bool>,
}
impl Fake {
fn said(&self, what: &str) {
self.asked
.lock()
.unwrap_or_else(|e| e.into_inner())
.push(what.into());
}
}
impl Owner for Fake {
fn close_books(&self) -> Result<String, String> {
Err("not in these tests".into())
}
fn new_books(&self, _: &str, _: &str) -> Result<String, String> {
Err("not in these tests".into())
}
fn back_up_now(&self, _: &str) -> Result<Vec<String>, String> {
Err("not in these tests".into())
}
fn plan_restore(&self, _: &str, _: Option<String>, _: bool) -> Result<RestorePlan, String> {
Err("not in these tests".into())
}
fn restore(&self, _: &RestorePlan) -> Result<Vec<String>, String> {
Err("not in these tests".into())
}
fn connect(&self, _: ledger_gui::owner::Agent, _: &str) -> Result<Vec<String>, String> {
Err("not in these tests".into())
}
fn upgrade_format(&self, _: &str, _: &str) -> Result<Vec<String>, String> {
Err("not in these tests".into())
}
fn update_waiting(&self) -> Option<UpdateWaiting> {
(*self.staged.lock().unwrap()).then(|| UpdateWaiting {
version: "9.1.0".into(),
staged_at: "2026-10-30T08:00:00Z".into(),
})
}
fn check_for_updates(&self) -> Result<Vec<String>, String> {
self.said("check");
*self.staged.lock().unwrap() = true;
Ok(vec!["Version 9.1.0 is downloaded and checked.".into()])
}
fn apply_update(&self) -> Result<Vec<String>, String> {
self.said("apply");
*self.staged.lock().unwrap() = false;
Ok(vec!["Version 9.1.0 is installed.".into()])
}
}
fn alone() -> std::sync::MutexGuard<'static, ()> {
static ONE: Mutex<()> = Mutex::new(());
ONE.lock().unwrap_or_else(|e| e.into_inner())
}
fn fake() -> Arc<Fake> {
static FAKE: OnceLock<Arc<Fake>> = OnceLock::new();
FAKE.get_or_init(|| {
let f = Arc::new(Fake::default());
ledger_gui::owner::set_owner(f.clone());
f
})
.clone()
}
fn today() -> NaiveDate {
NaiveDate::from_ymd_opt(2026, 10, 15).unwrap()
}
/// Books in a data root (`root`: a folder holding the root's marker), or
/// outside one (as the demo's are).
fn books(root: bool) -> (tempfile::TempDir, PathBuf, Books) {
let dir = tempfile::Builder::new()
.prefix("wb-gui-owner-")
.tempdir()
.unwrap();
let book = if root {
std::fs::write(dir.path().join(ledger_store::root::MARKER_FILE), b"").unwrap();
dir.path().join("books").join("acme")
} else {
dir.path().join("books-data")
};
std::fs::create_dir_all(&book).unwrap();
drop(Store::create(&book, Currency::USD).unwrap());
let b = data::load_once(&Source {
data_dir: book.clone(),
today: Some(today()),
})
.unwrap();
(dir, book, b)
}
fn harness(b: Books) -> Harness<'static, App> {
let mut h = Harness::builder()
.with_size(egui::vec2(1200.0, 900.0))
.build_eframe(|cc| App::with_books(&cc.egui_ctx, b, false, Route::Overview));
h.run_steps(3);
h
}
fn wait_for(h: &mut Harness<'static, App>, f: &Fake, n: usize) {
for _ in 0..200 {
h.run_steps(2);
if f.asked.lock().unwrap().len() >= n {
h.run_steps(4);
return;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
panic!("the owner wasn't asked: {:?}", f.asked.lock().unwrap());
}
#[test]
fn the_menu_checks_for_updates_and_a_waiting_update_shows_a_notice_that_accepts() {
let _alone = alone();
let f = fake();
let (_dir, _book, b) = books(true);
let mut h = harness(b);
// Nothing waits: no notice.
assert!(h.query_by_label_contains("is ready.").is_none());
// "Check for updates" asks the owner to look; what it finds shows.
h.get_by_label_contains(" Books").click();
h.run_steps(3);
h.get_by_label_contains("Check for updates").click();
wait_for(&mut h, &f, 1);
assert_eq!(f.asked.lock().unwrap()[0], "check");
// The job says so, and the notice says so too: either is the same
// version, so ask for at least one rather than exactly one.
assert!(
h.query_all_by_label_contains("downloaded and checked")
.next()
.is_some()
);
// The standing notice names the version; accepting hands it to the
// owner, and the notice goes once it is installed.
for _ in 0..60 {
h.run_steps(2);
if h.query_by_label_contains("Version 9.1.0 of wanderbooks is ready")
.is_some()
{
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
assert!(
h.query_by_label_contains("Version 9.1.0 of wanderbooks is ready")
.is_some(),
"no notice"
);
h.get_by_label("Update now").click();
wait_for(&mut h, &f, 2);
assert_eq!(f.asked.lock().unwrap()[1], "apply");
for _ in 0..60 {
h.run_steps(2);
if h.query_by_label_contains("is ready.").is_none() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
assert!(h.query_by_label_contains("is ready.").is_none());
}
#[test]
fn books_outside_a_data_root_have_no_update_notice() {
let _alone = alone();
let f = fake();
*f.staged.lock().unwrap() = true;
let (_dir, _book, b) = books(false);
let h = harness(b);
assert!(h.query_by_label_contains("is ready.").is_none());
*f.staged.lock().unwrap() = false;
}
+1 -90
View File
@@ -1031,90 +1031,6 @@ pub fn close_books(common: &Common) -> Result<String, String> {
}
}
/// An update the host has downloaded and checked, waiting for the owner
/// (the viewer's notice; P11a step 4).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UpdateWaiting {
/// Its version.
pub version: String,
/// When it was staged (UTC, RFC 3339).
pub staged_at: String,
}
/// The update waiting for the owner's accept, if there is one.
pub fn update_waiting(common: &Common) -> Option<UpdateWaiting> {
let found = ledger_store::root::Root::find(common.root.as_deref()).ok()?;
let u = found.staged_update()?;
// One this program already is (or is older than) is not waiting: the
// next look drops it.
let newer = crate::update::parse_version(&u.version)
.zip(crate::update::parse_version(
crate::update::program_version(),
))
.is_some_and(|(staged, mine)| staged > mine);
newer.then_some(UpdateWaiting {
version: u.version,
staged_at: u.staged_at,
})
}
/// "Check for updates": look at the feed now, whatever the daily look
/// last did, and stage a newer release if there is one (it never swaps
/// the program: accepting is [`apply_update`]). What came of it.
pub fn check_for_updates(common: &Common) -> Result<String, String> {
let found =
ledger_store::root::Root::find(common.root.as_deref()).map_err(|e| e.to_string())?;
let mine = crate::update::program_version();
if let crate::update::Install::PackageManaged(manager) = crate::update::this_install()? {
return Err(format!(
"this install is updated by {manager}, not here: use the package manager"
));
}
match crate::update::stage_if_due(&found, mine, Duration::ZERO) {
Ok(Some(version)) => Ok(format!(
"Version {version} is downloaded and checked. Accept it to start using it."
)),
Ok(None) => Ok(match update_waiting(common) {
Some(u) => format!(
"Version {} is downloaded and checked. Accept it to start using it.",
u.version
),
None => format!("You have the newest version ({mine})."),
}),
Err(why) => Err(why.to_string()),
}
}
/// Accept the staged update: the books' host installs it and restarts
/// onto it once no call is running, the books staying open meanwhile
/// (the lease socket's apply_update). With no host running, it is
/// installed here, since no call can be cut off. What came of it.
pub fn apply_update(common: &Common) -> Result<String, String> {
let found =
ledger_store::root::Root::find(common.root.as_deref()).map_err(|e| e.to_string())?;
let run = found.run_dir().map_err(|e| e.to_string())?;
let said = |detail: Option<String>| detail.unwrap_or_else(|| "The update is accepted.".into());
match crate::opener::ask_once_within(&run, Ask::Intent(Intent::ApplyUpdate), UPDATE_WAIT)? {
Some(HostMsg::Done { detail }) => Ok(said(detail)),
Some(HostMsg::Refused(why)) => Err(why),
Some(HostMsg::Uncertain(why)) => Err(why),
Some(other) => Err(format!("the books' host answered oddly: {other:?}")),
None => match crate::update::apply_staged(&found, crate::update::program_version()) {
Ok(Some(version)) => Ok(format!(
"Version {version} is installed; it starts the next time the books open."
)),
Ok(None) => Err(
"no newer version waits, or this install is updated by its package manager".into(),
),
Err(why) => Err(why.to_string()),
},
}
}
/// The longest the host may take to install an accepted update (a copy
/// and a health check of the new program).
const UPDATE_WAIT: Duration = Duration::from_secs(90);
/// The longest an agent's own command may take to add the books.
const CONNECT_WAIT: Duration = Duration::from_secs(60);
@@ -1262,12 +1178,6 @@ pub fn connect_agent(common: &Common, agent: &str, book: &str) -> Result<Vec<Str
"{label} isn't installed here (no `{cli}` on the PATH, nor where its installer puts it)"
)
})?;
// A translocated macOS app's path (P11a step 6) is not a place to point
// an agent: the copy goes away with the app.
let exe = crate::start::this_executable()?;
if let Some(why) = crate::start::translocation_refusal(&exe) {
return Err(why.to_string());
}
let mut lines = Vec::new();
// The token: kept in its file, or made now.
let found =
@@ -1285,6 +1195,7 @@ pub fn connect_agent(common: &Common, agent: &str, book: &str) -> Result<Vec<Str
));
}
// The agent's own list: the books' entry made anew.
let exe = crate::start::this_executable()?;
let name = format!("wanderbooks-{book}");
let mut serve: Vec<std::ffi::OsString> = vec![
exe.into_os_string(),
+1 -52
View File
@@ -648,9 +648,6 @@ impl Host {
Event::Intent(Intent::ConsentFormatUpgrade { book, passphrase }, answer) => {
self.consent(book, passphrase.unwrap_or_default(), answer);
}
Event::Intent(Intent::ApplyUpdate, answer) => {
self.apply_update(answer);
}
Event::Intent(Intent::Close, answer) => {
let _ = answer.send(HostMsg::Done {
detail: Some("the books are closing".into()),
@@ -718,7 +715,7 @@ impl Host {
let (root, every, looking) = (self.root.clone(), self.knobs.update_check, looking.clone());
std::thread::spawn(move || {
match crate::update::stage_if_due(&root, crate::update::program_version(), every) {
Ok(Some(version)) => eprintln!("wanderbooks host: version {version} is downloaded and checked; it starts when you accept it in the app, the next time the books open, or at `wanderbooks update`"),
Ok(Some(version)) => eprintln!("wanderbooks host: version {version} is downloaded and checked; it starts the next time the books open, or at `wanderbooks update`"),
Ok(None) => {}
Err(why) => eprintln!("wanderbooks host: the look for a newer version failed: {why}"),
}
@@ -863,54 +860,6 @@ impl Host {
}
}
/// The owner's accept of the staged update (P11a step 4): install it
/// now, as `wanderbooks update` does, and let the host's watch for a
/// replaced program (INVARIANT 7) restart onto it once no call is
/// running: the server finishes its calls before it closes, and the
/// leases carry on across it. The answer is sent when the file is
/// in place (or why not), from a thread, so the loop never waits.
fn apply_update(&self, answer: oneshot::Sender<HostMsg>) {
if !matches!(self.phase, Phase::Open) {
let _ = answer.send(HostMsg::Refused(
"the books are closing or restarting: ask again in a moment".into(),
));
return;
}
#[cfg(unix)]
{
let root = self.root.clone();
std::thread::spawn(move || {
let said = match crate::update::apply_staged(
&root,
crate::update::program_version(),
) {
Ok(Some(version)) => {
eprintln!(
"wanderbooks host: version {version} accepted: restarting onto it when no call is running"
);
HostMsg::Done {
detail: Some(format!(
"version {version} is installed; the books restart onto it as soon as no call is running, and stay open meanwhile"
)),
}
}
Ok(None) => HostMsg::Refused(
"no newer version waits, or this install is updated by its package manager"
.into(),
),
Err(why) => HostMsg::Refused(why.to_string()),
};
let _ = answer.send(said);
});
}
#[cfg(not(unix))]
{
let _ = answer.send(HostMsg::Refused(
"updating from the app isn't built for this system yet".into(),
));
}
}
/// Close the server's input: it closes the books (INVARIANT 3).
fn close_server(&mut self) {
self.server_stdin = None;
+3 -9
View File
@@ -16,8 +16,7 @@
//! 1. A connection's first line is one Ask; a Hold keeps the
//! connection (the lease lasts until it closes), anything else is
//! answered once and the connection closed.
//! 2. Exactly three intents exist: close, consent_format_upgrade and
//! apply_update; any
//! 2. Exactly two intents exist: close and consent_format_upgrade; any
//! other request is refused.
//! 3. consent_format_upgrade carries the owner passphrase; without one
//! it is refused before anything is done. With one, the host hands it
@@ -107,7 +106,7 @@ pub enum Ask {
Intent(Intent),
}
/// The owner's intents: exactly these three (INVARIANT 2).
/// The owner's intents: exactly these two (INVARIANT 2).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum Intent {
@@ -121,10 +120,6 @@ pub enum Intent {
#[serde(default)]
passphrase: Option<ledger_mcp::admin::Passphrase>,
},
/// Accept the update the host has staged (P11a step 4): the host
/// installs it, and restarts onto it once no call is running, the
/// books staying open to every holder across it.
ApplyUpdate,
}
impl Intent {
@@ -263,10 +258,9 @@ mod tests {
}
#[test]
fn exactly_three_intents_are_understood() {
fn exactly_two_intents_are_understood() {
for intent in [
json!("close"),
json!("apply_update"),
json!({"consent_format_upgrade": {"book": "acme", "passphrase": "x"}}),
] {
let h = parse(json!({"proto": 1, "intent": intent})).unwrap();
-37
View File
@@ -83,21 +83,6 @@ pub fn this_executable() -> Result<PathBuf, String> {
Ok(exe)
}
/// Why this program can't point an agent at itself, if it can't (P11a
/// step 6): macOS runs a quarantined app from a read-only copy under
/// `/private/var/folders/…/AppTranslocation/…`, a path that goes away when
/// the app closes. The app may run, but an agent connected from it would
/// lose the books, so the person moves it out of Downloads first.
/// `exe` is the path this program would give the agent
/// ([`this_executable`]).
pub fn translocation_refusal(exe: &Path) -> Option<&'static str> {
exe.components()
.any(|part| part.as_os_str() == "AppTranslocation")
.then_some(
"wanderbooks is running from the copy macOS makes of a quarantined download, and that copy goes away when the app closes: move wanderbooks out of Downloads (Applications, for example), open it there, and connect the agent again",
)
}
/// The AppImage this process runs from: `APPIMAGE` when it is an
/// absolute path to a file.
pub fn own_appimage(value: Option<&std::ffi::OsStr>) -> Option<std::ffi::OsString> {
@@ -648,28 +633,6 @@ mod tests {
let exe = this_executable().unwrap();
assert!(exe.is_file(), "{exe:?}");
}
#[test]
fn only_a_translocated_path_refuses_an_agent() {
assert_eq!(
translocation_refusal(Path::new(
"/Applications/Wanderbooks.app/Contents/MacOS/wanderbooks"
)),
None
);
let copy = translocation_refusal(Path::new(
"/private/var/folders/xy/T/AppTranslocation/1234/d/Wanderbooks.app/Contents/MacOS/wanderbooks",
))
.expect("a translocated path is refused");
assert!(copy.contains("out of Downloads"), "{copy}");
// A part that only shares letters is not the marker macOS makes.
assert_eq!(
translocation_refusal(Path::new(
"/Applications/AppTranslocation Notes/wanderbooks"
)),
None
);
}
}
#[cfg(all(test, windows))]
+2 -6
View File
@@ -157,8 +157,7 @@ pub fn install_kind(exe: &Path, appimage: Option<&Path>) -> Install {
Install::SelfUpdating(exe.to_path_buf())
}
/// How the running program was installed.
pub fn this_install() -> Result<Install, String> {
fn this_install() -> Result<Install, String> {
let exe = crate::start::this_executable()?;
let image = std::env::var_os("APPIMAGE").map(PathBuf::from);
Ok(install_kind(&exe, image.as_deref()))
@@ -495,10 +494,7 @@ pub fn apply_staged(
let Install::SelfUpdating(target) = this_install()? else {
return Ok(None);
};
// The host's own look at the feed can still hold the lock for a moment
// after it writes `meta.json`; the owner's accept should wait that out,
// not answer "nothing waits" (P11a step 4).
let Ok(_lock) = take_lock_within(&updates, Duration::from_secs(5)) else {
let Ok(_lock) = take_lock(&updates) else {
return Ok(None);
};
let meta: serde_json::Value = std::fs::read(dir.join(META))
+6
View File
@@ -39,12 +39,18 @@ toml = "1"
# The vault's TINs, decoded for the filing scan (tin_files.rs), are wiped
# from memory when dropped, as the vault's own are.
zeroize = "1"
tempfile = { version = "3", optional = true }
# The socket's peer credentials (SO_PEERCRED, getpeereid) for local.rs's
# same-user check: std has no call for them.
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[features]
# The door in-process, for fuzz/ (local.rs fuzz_door). Off in every build
# but the fuzz workspace's.
fuzzing = ["dep:tempfile"]
[lints]
workspace = true
+17 -7
View File
@@ -1730,6 +1730,22 @@ async fn mcp(
})
}
/// The one router both doors serve (the local socket and the tunnel).
fn router(books: Arc<Books>) -> axum::Router {
axum::Router::new()
.route("/b/{book}/mcp", axum::routing::any(mcp))
.route("/b/{book}/u/{upload}", axum::routing::put(upload))
.route("/b/{book}/a/{attachment}", axum::routing::get(download))
.route("/b/{book}/x/{export}", axum::routing::get(export))
.fallback(not_found)
.with_state(books)
}
/// The door in-process, for the fuzz targets (P12): the same router, fed
/// bytes without a socket. Not built unless asked for.
#[cfg(feature = "fuzzing")]
pub mod fuzz_door;
/// Anything but `/b/{book}/mcp`.
async fn not_found() -> (axum::http::StatusCode, &'static str) {
(axum::http::StatusCode::NOT_FOUND, "not found")
@@ -2080,13 +2096,7 @@ async fn serve(
}
});
let app = axum::Router::new()
.route("/b/{book}/mcp", axum::routing::any(mcp))
.route("/b/{book}/u/{upload}", axum::routing::put(upload))
.route("/b/{book}/a/{attachment}", axum::routing::get(download))
.route("/b/{book}/x/{export}", axum::routing::get(export))
.fallback(not_found)
.with_state(books.clone());
let app = router(books.clone());
// The HTTP side keeps answering (refusing, once closing) until the
// close is done: a client is never cut off mid-request by it.
let shut = CancellationToken::new();
+187
View File
@@ -0,0 +1,187 @@
//! The books server's HTTP door, in-process, for the fuzz targets
//! (`fuzz_http_mcp_request`, `fuzz_upload_put`; P12). It is the very router
//! `serve` mounts (`router`), over a real book in a folder of its own, fed
//! requests by `tower::oneshot`: no socket, no port. Behind the `fuzzing`
//! feature, which only the fuzz workspace turns on.
//!
//! Oracle: whatever the bytes, the door answers (no panic, no hang), and
//! never lets in a request without a token live for the book (INVARIANT 8)
//! or bytes that are not the ones declared (INVARIANT 9).
use super::*;
use tower::ServiceExt;
/// One book `acme`, two tokens (`bearer`, `other`), and the router.
pub struct FuzzDoor {
rt: tokio::runtime::Runtime,
books: Arc<Books>,
app: axum::Router,
bearer: String,
token_id: String,
other: String,
_home: tempfile::TempDir,
}
impl FuzzDoor {
/// A fresh book in a fresh folder.
pub fn new() -> Self {
let home = tempfile::tempdir().expect("a folder");
let (root, _) =
Root::setup(home.path(), Some(&home.path().join("root")), None).expect("a data root");
let books = Arc::new(Books {
root,
held: RwLock::new(BTreeMap::new()),
sessions: CancellationToken::new(),
closing: CancellationToken::new(),
in_flight: AtomicUsize::new(0),
finished: tokio::sync::Notify::new(),
last_arrival: std::sync::atomic::AtomicU64::new(0),
started: Instant::now(),
principals: RwLock::new(Err(String::new())),
session_tokens: Mutex::new(BTreeMap::new()),
backup_quiet: BACKUP_QUIET,
drill_first: DRILL_FIRST,
restoring: Mutex::new(()),
isolation: Isolation::SameUser,
});
let loaded = books.load_principals();
*books.principals.write().unwrap() = loaded;
match books.admin(Request::Create {
id: "acme".into(),
currency: "USD".into(),
}) {
Response::Created { problem: None, .. } => {}
other => panic!("{other:?}"),
}
let token = |name: &str| match books.admin(Request::TokenCreate {
book: "acme".into(),
name: name.into(),
scopes: ["read", "write", "post", "admin"]
.map(String::from)
.to_vec(),
replace: true,
}) {
Response::TokenCreated { token, text } => (token.id, text.0),
other => panic!("{other:?}"),
};
let (token_id, bearer) = token("fuzz");
let (_, other) = token("fuzz-other");
let app = router(books.clone());
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.expect("a runtime");
FuzzDoor {
rt,
books,
app,
bearer,
token_id,
other,
_home: home,
}
}
/// The text of the token that is live for the book.
pub fn bearer(&self) -> &str {
&self.bearer
}
/// The text of a second live token (the one an upload is not its).
pub fn other_bearer(&self) -> &str {
&self.other
}
/// An upload granted to the live token: its id, or why not.
pub fn grant(&self, sha256: &str, size: u64) -> Result<String, String> {
let held = self.books.held();
let Some(Book::Open { uploads, .. }) = held.get("acme") else {
return Err("acme isn't open".into());
};
uploads
.grant(
&self.token_id,
sha256,
size,
"application/pdf",
"fuzz.pdf",
"document",
chrono::Utc::now().timestamp(),
)
.map(|g| g.id)
.map_err(|e| e.to_string())
}
/// Whether the door would take one of these headers as a bearer token
/// live for the book: the same check every request passes, so a target
/// never has to model the rule a second time.
pub fn carries_a_token(&self, headers: &[(Vec<u8>, Vec<u8>)]) -> bool {
let mut map = axum::http::HeaderMap::new();
for (name, value) in headers {
let (Ok(name), Ok(value)) = (
axum::http::HeaderName::from_bytes(name),
axum::http::HeaderValue::from_bytes(value),
) else {
return false;
};
map.append(name, value);
}
self.books.bearer(&map).is_ok()
}
/// Send a request as the router receives it; the status and the body
/// of the answer, or `None` when the pieces do not make an HTTP request
/// at all (a method or header the HTTP types refuse never reaches the
/// door in life either).
pub fn send(
&self,
method: &[u8],
path: &str,
headers: &[(Vec<u8>, Vec<u8>)],
body: Vec<u8>,
) -> Option<(u16, Vec<u8>)> {
let mut request = axum::http::Request::builder()
.method(axum::http::Method::from_bytes(method).ok()?)
.uri(
format!("http://localhost{path}")
.parse::<axum::http::Uri>()
.ok()?,
);
for (name, value) in headers {
request = request.header(
axum::http::HeaderName::from_bytes(name).ok()?,
axum::http::HeaderValue::from_bytes(value).ok()?,
);
}
let request = request.body(axum::body::Body::from(body)).ok()?;
let app = self.app.clone();
Some(self.rt.block_on(async move {
let response = app.oneshot(request).await.unwrap_or_else(|e| match e {});
let status = response.status().as_u16();
// An event stream never ends on its own: read what comes in a
// moment, as a client that hangs up would.
let bytes = tokio::time::timeout(
Duration::from_millis(200),
axum::body::to_bytes(response.into_body(), 1 << 20),
)
.await
.ok()
.and_then(Result::ok)
.unwrap_or_default();
(status, bytes.to_vec())
}))
}
/// Sessions the door has bound (they only grow, so the target starts
/// a fresh door now and then).
pub fn sessions(&self) -> usize {
self.books.session_tokens.lock().unwrap().len()
}
}
impl Default for FuzzDoor {
fn default() -> Self {
Self::new()
}
}
-35
View File
@@ -242,17 +242,6 @@ pub struct FormatUpgradeOut {
pub how: String,
}
/// An update the host has downloaded and checked, waiting for the owner.
#[derive(Debug, Serialize, JsonSchema)]
pub struct UpdateWaitingOut {
/// The version waiting.
pub version: String,
/// When the host staged it (UTC, RFC 3339).
pub staged_at: String,
/// Who can accept it, and how.
pub how: String,
}
/// Something to do next.
#[derive(Debug, Serialize, JsonSchema)]
pub struct NextStepOut {
@@ -402,10 +391,6 @@ pub struct StartOut {
/// the owner gives the go-ahead. No tool can: tell the human.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub format_upgrade: Option<FormatUpgradeOut>,
/// A newer version the host has downloaded and checked (P11a step 4):
/// the owner accepts it in the app. No tool can: tell the human.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub update_waiting: Option<UpdateWaitingOut>,
/// Who is asking.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub you: Option<YouOut>,
@@ -978,25 +963,6 @@ impl BooksServer {
"Tell the human first: these books wait for a format upgrade, which only the owner can agree to (in the app, which asks when it opens the books, or `wanderbooks books upgrade` at a terminal, with the owner passphrase; a verified backup is made first). Until then they work on their old format and refuse what needs the new one (format_upgrade_pending). No tool can upgrade them.",
));
}
// A newer version waits for the owner (P11a step 4): said once,
// after a format upgrade, which comes first.
let update_waiting = ledger_store::root::root_of_book_dir(store.data_dir())
.and_then(ledger_store::root::staged_update_in)
.map(|u| UpdateWaitingOut {
version: u.version,
staged_at: u.staged_at,
how: "only the owner accepts it: in the app (Books, then Update), or `wanderbooks update` at a terminal. No tool can.".to_string(),
});
if let Some(u) = &update_waiting {
next_steps.push(step(
"books.handbook",
None,
&format!(
"Tell the human: version {} of wanderbooks is downloaded and checked, and waits for them to accept it in the app (Books, then Update). Nothing changes until they do; no tool can.",
u.version
),
));
}
if onboarding || focus == "onboarding" {
next_steps.push(NextStepOut {
tool: None,
@@ -1274,7 +1240,6 @@ impl BooksServer {
title: handbook::title(md).to_string(),
})
.collect(),
update_waiting,
format_upgrade: upgrade.map(|u| FormatUpgradeOut {
guard: ledger_store::error::FORMAT_UPGRADE_PENDING.to_string(),
format: u.from.format,
-45
View File
@@ -661,12 +661,6 @@ impl Root {
.map_err(|e| self.hide(e))
}
/// The update the host has downloaded, checked and staged under
/// `updates/staged/`, if one waits (P11a step 4).
pub fn staged_update(&self) -> Option<StagedUpdate> {
staged_update_in(&self.path)
}
/// The folder a book lives in.
pub fn book_dir(&self, id: &BookId) -> PathBuf {
self.path.join(BOOKS_DIR).join(id.as_str())
@@ -2400,42 +2394,3 @@ mod unit {
assert_eq!(keys, book.join("keys"));
}
}
/// An update waiting in `<root>/updates/staged/`: downloaded, checked and
/// kept by the host, applied when the owner accepts it or the books next
/// start.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StagedUpdate {
/// Its version.
pub version: String,
/// When it was staged, UTC (RFC 3339).
pub staged_at: String,
}
/// The update staged in the data root `root`, if one waits. What it says
/// is shown to people and agents, so a version that is not plainly one
/// (digits, letters, dots, dashes, at most 40) is no update.
pub fn staged_update_in(root: &Path) -> Option<StagedUpdate> {
let meta = root.join("updates").join("staged").join("meta.json");
let value: serde_json::Value = serde_json::from_slice(&std::fs::read(&meta).ok()?).ok()?;
let version = value["version"].as_str()?;
let plain = !version.is_empty()
&& version.len() <= 40
&& version
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+'));
if !plain {
return None;
}
let at: chrono::DateTime<chrono::Utc> = std::fs::metadata(&meta).ok()?.modified().ok()?.into();
Some(StagedUpdate {
version: version.to_string(),
staged_at: at.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
})
}
/// The data root a book's folder (`<root>/books/<id>`) is in, if it is one.
pub fn root_of_book_dir(book_dir: &Path) -> Option<&Path> {
let root = book_dir.parent()?.parent()?;
root.join(MARKER_FILE).is_file().then_some(root)
}
+1 -15
View File
@@ -417,19 +417,7 @@ downgrading is theirs). Anything else (an AppImage, whose file is named by
call is cut off), after a second check against the key, or by
`wanderbooks update`, the owner's say-so, which reuses the staged copy. A
local release is named in `<root>/updates/feed` (a `file://` address, then
the key file), the same rule as `--trusted-key`;
- **the owner accepts one in the app**: the viewer reads the staged
`meta.json` (`Owner::update_waiting`) and shows a notice with "Update
now", and Books has "Check for updates" (`Owner::check_for_updates`) and
"Update to X"; accepting is the host's lease intent `apply_update`, which
installs the staged copy as `update` does and lets the host's own
replaced-program watch restart onto it once no call runs. `books.start`
carries the same notice to an agent as `update_waiting`, with a next step
to tell the human; no tool accepts it;
- **a translocated macOS app refuses to connect agents**
(`start::translocation_refusal`): a quarantined download runs from a
read-only copy under `AppTranslocation` that vanishes with the app, so
the person moves it out of Downloads first.
the key file), the same rule as `--trusted-key`.
`deploy/packaging/` holds the Linux packaging: `packager.toml`
(cargo-packager: `.deb`, AppImage, the pacman tarball with its PKGBUILD),
@@ -440,8 +428,6 @@ throwaway-keyed feed on this machine) and `test-linux-packages.sh`.
`.github/workflows/release.yml` builds them on a version tag. The macOS
bundle is `packager-macos.toml`, `Info.plist` and `build-macos.sh` (an
unsigned `.dmg` from CI's `macos` job; signing and notarization are step 5).
A translocated app (Gatekeeper's copy of a quarantined download) may run
but refuses to connect an agent, saying how to move it out of Downloads.
## The result envelope and the guards (`ledger-mcp`, P5)
+1 -8
View File
@@ -124,8 +124,7 @@ settings-and-conventions section says how to use them.
codes), backup: { last_at, through_seq, stale }, prompts: [{ name, args
}], needs_attention: { items: [{ id, severity, kind, title, tool?, args?
}], counts: { kind: n } }, truncated?, handbook_sections,
format_upgrade?: { guard, format, adds, how }, update_waiting?: { version,
staged_at, how }, next_steps: [{ tool |
format_upgrade?: { guard, format, adds, how }, next_steps: [{ tool |
prompt, args?, why, required }], isolation }`. The connect text names it
as the first call. `book.url` is the book's path on this server
(`/b/ID/mcp`), never a path on a disk. `you` is the token's own facts
@@ -146,12 +145,6 @@ settings-and-conventions section says how to use them.
(below); its next step comes first, before anything else, at every
start until it is done: `books.handbook {section: "format-upgrade"}`,
"Tell the human first: …".
`update_waiting` is there only while the host has downloaded and checked
a newer version of wanderbooks (`<root>/updates/staged/`): its version,
when it was staged (UTC, RFC 3339) and how it may be accepted. Only the
owner can accept it, in the app (Books, then Update) or with
`wanderbooks update` at a terminal; no tool can. Its next step asks the
agent to tell the human, and nothing changes until they accept.
`isolation` is `same_user (advisory)` locally (D-32): the books are
hidden from agents in the owner's own account, not locked against them.
On the owner's own machine, where the server runs as a separate account
+5 -28
View File
@@ -135,17 +135,8 @@ subset. **Not checked: that the `.dmg` builds, mounts or runs**: that needs
the CI run (Actions, release, Run workflow, this branch) and then a Mac.
Open items for the trial on a Mac: whether `wanderbooks update` replacing
the program inside a signed `.app` breaks its signature (it will: step 5
must decide, probably by updating a whole `.app`). **Translocation
(P11a step 6):** a `.dmg` downloaded from the internet keeps its
quarantine bit, so Gatekeeper may run the app from a read-only copy under
`/private/var/folders/…/AppTranslocation/…` that goes away when it
closes. `wanderbooks connect` (and the viewer's Connect, which is the same
function) refuses there, before a token is made or an agent's list is
touched, and says to move the app out of Downloads and open it again:
`ledger_host::start::translocation_refusal` reads the path the program
would give the agent. Checked on Linux by standing in for that path
(`translocated_app_refuses_to_connect`, `only_a_translocated_path_refuses_an_agent`);
the real Gatekeeper copy still needs the trial on a Mac.
must decide, probably by updating a whole `.app`); the translocated-app
check (`translocated_app_refuses_to_connect`) is not built.
## Updates staged by the host (step 4)
@@ -157,23 +148,9 @@ first; `wanderbooks update` applies it too and reuses the staged copy;
`update --check` says "already downloaded". A local release is named by
`<root>/updates/feed` (a `file://` address, then the key file). Without a
compiled-in release key and without that file, the look does nothing but
log why.
The owner accepts a staged update **in the app** (P11a step 4). The
viewer reads `<root>/updates/staged/meta.json` every few seconds
(`Owner::update_waiting`) and shows a standing notice above the page,
"Version X of wanderbooks is ready", with "Update now"; Books also has
"Check for updates" (`Owner::check_for_updates`, which looks at the feed
at once whatever the daily look did) and "Update to X" while one waits.
Accepting sends the host's lease intent `apply_update`; the host installs
the staged copy exactly as `wanderbooks update` does and its existing
watch for a replaced program (host.rs INVARIANT 7) restarts onto it once
no call is running, the books staying open and every holder's session
carrying on. With no host running there is no call to cut off, so the
viewer applies it itself. `books.start` tells an agent the same thing
(`update_waiting`) and adds a next step to tell the human; no tool can
accept it. The accepted update replaces the program file, not a whole
`.app`, which step 5 must revisit for a signed bundle.
log why. Not built: the in-app "restart now to update" choice and the
viewer's notice, which need an admin-socket intent and a place in the
viewer (see the unit's As-built block).
## What `update` and `rollback` need from a release
+542 -45
View File
@@ -622,6 +622,7 @@ scratch (`ledger-store` `backup.rs` and `root.rs`, `ledger-core`
`settings.rs`, `ledger-mcp` `backup.rs`, `tools/backup.rs`, `local.rs`,
`admin.rs`, `ledger-host` `commands.rs`, `ledger-cli`). The survivor lists
are kept with the run's output, outside the repository.
*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)*
## P3: odd cents (2026-10-20)
@@ -729,6 +730,7 @@ parse; a tag that isn't text; the feature declaration);
fields; `Currency::allocation_places`. P3's accept counts `ledger-core` and
`ledger-import` (R-6); the store and MCP mutants above are for the whole
pass.
*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)*
## P3: the IIF and as-of (2026-10-20)
@@ -934,6 +936,7 @@ condition and `receivable_payable`; `fonts.rs` and `theme.rs`
`font_definitions`; the README's A/R and A/P sentence; `ledger-core`
`parties.rs` `tin_separator`'s arms and `tin_invisible`, and the gap that
doesn't count them.
*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)*
## P3: cash flow (defect 1, 2026-10-20)
@@ -1080,6 +1083,7 @@ the end.
`export_cash_flow` and `settings_problems`; zero unexplained survivors
(plan P3 accept). Likely equivalent, to adjudicate: `min` against `max`
in `take` where both bounds coincide, and the kind order's tie-breaks.
*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)*
## P3: rename, defaults and tax categories (2026-10-20)
@@ -1267,6 +1271,7 @@ range, `export_journal`'s period, `described`, `uncategorized_warning`,
`created_uncategorized`, the digest's `uncategorized_accounts`,
`handbook::period_rule`. The viewer is not mutated (owner, 2026-10-18):
`view_model.rs` and `show.rs` cover it.
*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)*
## P3: parties (defects 14, 24, 11; 2026-10-20)
@@ -1316,6 +1321,7 @@ cash, in_kind) and `update_party`'s creditor_of diff; the viewer's
`party_what_for` and `share_text`. Expected equivalent:
`Party::tin_last4`'s `or_else` order swapped (TIN facts and W-9 facts
never disagree, parties INVARIANT 2a, enforced on replay).
*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)*
## P3: 1099s (defects 23, 12 and 18; 2026-10-20)
@@ -1375,6 +1381,7 @@ unchanged path, `set_vendor_w9_flag`, `add_instrument`,
first, only when the flag changes); `ledger-package` `vendor_1099.rs` (the CSV columns, every note's
guard, `may_file`, `missing_w9`'s suffix) and `package.rs` (tin_gaps, the
README line). The viewer has no mutation job (its musts are tested).
*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)*
## P3: parties and 1099s landed together (2026-10-21)
@@ -1398,6 +1405,7 @@ fallback); the server's `vendor_1099` (`through`'s year check, the
unknown-code check, the accounts passed) and `update_party`'s feature
declaration; the bookspec loader's party, payee, decision and payment
method setup.
*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)*
## P3: depreciation and schedules (defects 22 and 9, 2026-10-20)
@@ -1584,6 +1592,7 @@ version's journal format (rules.md B-27). Mutation testing waits for P12
(`consent_now`, `upgrade_format`, `offer_upgrades`, `offer_upgrade`,
`upgrade`, `book_line`, `upgraded_lines`, `upgrade_explained`,
`ASKED_UPGRADES`). None run.
*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)*
## P3: asset basis and tax-basis capital (2026-10-20)
@@ -1715,6 +1724,7 @@ reversal's take-back) and
`entry_property` (the mismatch branch, the unknown sum), the open-item
conditions (`in_service`, the disposal window), and the reconciliation's
signs.
*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)*
## P3: the seams between the pieces (2026-10-21)
@@ -1818,6 +1828,7 @@ before); the bookspec loader's exact-spelling filters and `check_k1`'s
`cash_accounts` check; `documented_tools` (its own sample checks it).
The earlier P3 sections' lists stand (one name corrected:
`Ledger::depreciation_runs`, which was `depreciation_runs_ever`).
*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)*
## P3: projects (2026-10-21)
@@ -1957,6 +1968,7 @@ balance-sheet zero; `project_set_of` (newer vs corrupt);
`set_project`'s unchanged short-cut; `subject_feature`; `check_link_to`'s
feature check; the decision `projects` omit-when-empty;
`late_tag_note`'s `is_over`; `remember_footer`'s threshold.
*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)*
## P3: the project snapshot (2026-10-22)
@@ -2068,6 +2080,7 @@ fail.
**For P12 (mutation targets):** `contribution_margin_pct`'s `<=` and
rounding strategy; `project_figures`' opening net and running sum;
`profit_and_loss_counted`'s counts; `Standing::of`'s order.
*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)*
## Invoices and bills take a project (2026-10-22)
@@ -2178,6 +2191,7 @@ order, the `contains` test); `add_document_parts`' tag index;
`document_notes`' dedup and its `invoice` flag; `on_spec_invoice_note`'s
kind test; `document_projects_rule`'s branch; the viewer's
`document_of` (invoice before bill).
*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)*
## After P3: the cutover review's open points 1 and 2 (2026-10-22)
@@ -2650,6 +2664,7 @@ amount match and one-line rule; `refresh_doc`'s settled sum;
`doc_status`'s Overpaid and Unattached arms; the aging's `<= 0`;
`ensure_payment_features`' comparison; `check_document_reversal`'s date
check; `control_check`'s chain netting.
*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)*
## P4b S1, part B: void, control check, and each payment's details (2026-10-24)
@@ -2702,6 +2717,7 @@ line and nothing needing attention, each field's mapping, the
invoice/bill kind); `control_problems` (one message per unattached line
and per document, both loops); the `digest` that includes it and the
`verify` check that refuses a nonzero difference.
*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)*
## P4b S2: reading documents, in the tools and in the viewer (2026-10-24)
@@ -2758,6 +2774,7 @@ Overdue's `overdue_days > 0`, Paid, Void, All), a document after the day
shown only under All (`Ok(None)`), the list's `(due, date, name)` sort
and the timeline's movement×reversal labels with their `(reversed later)`
condition (`!t.reversal && !stands_on(t.entry, b.today)`).
*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)*
## P4b S3: the invoice PDF (2026-10-24)
@@ -2811,6 +2828,7 @@ one does: `PartiallyPaid` (no sample is part paid and not yet due) and
`Draft` (only S4's preview reaches it; the renderer's own test renders
it but asserts only that it succeeds).
## P7: the TIN scanner's separators (2026-10-26)
*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)*
rules.md B-26; plan 8.2 P7, with vault v2 closed by D-48 (nothing of
HPKE, keyslots or human pages is built here). The scanner
@@ -2851,6 +2869,7 @@ Deleting any one of them leaves a TIN that the old scanner passed
either readable or unmasked, which the truth table and the property
catch.
## P13: bookspec, the checker crate and the runner (2026-10-26)
*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)*
bookspec's example schema moved out of the loader into
`bookspec/checker`, its own Cargo workspace inside the repository (P13
@@ -2918,6 +2937,7 @@ example" arm; the adapter's `event_of`, its `depreciation_schedule` month
span (acquisition through as-of, 0.00 where the schedule reaches no month)
and every `answer_*` figure against the loader's same check (the two must
agree on all 56 examples).
*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)*
## P13 steps 4 and 5: the specification text, the laws, the derivation, GnuCash (2026-10-29)
@@ -2983,6 +3003,7 @@ condition), `Model::post`'s close (a debit-normal balance credits, the
equity line's sign), `signed`'s date window and `closings` flag, and the
accounting-equation check; the spec test's pin search (a mutant that skips
the quote check passes only if `every_citation_resolves` has no bad pin).
*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)*
## U0b: one attention engine over `PackageBooks` (2026-10-26)
@@ -3038,6 +3059,7 @@ negative-zero test; `filter(|a| a.tool.is_some()).take(12)` and
intact-blob set (a damaged blob reported per attachment) and `Store::
disk_check`'s snapshot load and `Unreadable` arm; the viewer wrapper's
`Target` arms.
*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)*
## P9a: self-host, the move, and the static bearer over a tunnel (2026-10-26)
@@ -3118,6 +3140,7 @@ and `BooksServer::new`'s copy (a dropped field must fail
`isolation_label_is_enforced_under_the_unit`); the host's `server_flags`
extension and `parse_args`' passthrough of `--isolation` and its optional
`--http` value.
*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)*
## P4b S4.5: the issued PDF, the viewer and the handbook (2026-10-29)
@@ -3182,6 +3205,7 @@ arms and `InvoiceVersion::Current { as_of }`; the viewer's `details`
number-else-id and `issued_copy`'s PDF-and-description match; the
handbook's `documents_v2_rule` both arms and the server's `documents_v2`
feature test.
*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)*
## P4b S5: sent tracking (2026-10-27)
@@ -3246,6 +3270,7 @@ predicates in `ledger-package::attention`: `invoice.number.is_some()`
`checked_add_days` `unsent_days` comparison (`>`), `overdue_days > 0`, the
`sends.max()` last send, and the `reminder_days` comparison (`>`), plus
each item's target (`Target::Invoice`) and `books.mark_sent` step args.
*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)*
## P4b S6: paying on terms (2026-10-27)
@@ -3323,6 +3348,7 @@ arithmetic; `bills_due`'s `through` filter, the discount-window test and
the running-total grouping; `aging_documents`'s outstanding filter and
bucket; `aging_csv`'s tie-out columns and `aging_statement`'s totals; the
viewer's `discount_by` and `days_overdue` flags.
*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)*
## P4b S7: bad debts (write-offs), 2026-10-27
@@ -3400,6 +3426,7 @@ the blob-before-journal order, the `File`/`Existing` link steps) and the
feature gate; the MCP handler's amount default, the exactly-one evidence
check, and the preview's clone; attention's year comparison and its
`stands_on` filter; the bookspec loader's `may_refuse` arm.
*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)*
## P6: the evidence pipeline (2026-10-27)
@@ -3532,6 +3559,7 @@ arms, the 8 KiB cut, the 64 KiB hand-over threshold and `page - 1`.
`ledger-package::attention`: the extraction `seq` comparison and the
`Failed` filter, the owed-account name test, the credit side and the
`settles_entry` skip.
*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)*
## P11a step 0: keyring stores for macOS and Windows (2026-10-29)
@@ -3556,6 +3584,7 @@ Secret Service run needs a desktop): the CI run is the evidence.
**For P12 (mutation targets, none run, D-33):** `tokens.rs`'s `os` modules
are not reachable by mutation testing (they need the real OS store); the
`Items` logic they share is, through the file store.
*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)*
## U0e: tokens in a file, the OS keyring removed (2026-10-29, D-63)
@@ -3594,6 +3623,7 @@ token" must die on `an_unreadable_token_file_is_an_error_never_no_token`),
`Keeper::delete`'s `NotFound` arm, and `Account::key`'s three parts
(`an_account_names_the_root_by_hash_and_the_book_and_token`);
`bridge.rs::token_file_refusal`'s `holds_token` guard.
*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)*
## P4: draft states, the evidence gate, the preview digest (2026-10-29)
@@ -3669,6 +3699,7 @@ it); the digest's `since_seq` comparison (`>`), its `as_of` bound, the
`zero_net_accounts` branch and the `never_reconciled` split;
`report_status`'s three open conditions; the open-drafts refusal's
`<= as_of` and its decision; the attention `awaiting_evidence` branch.
*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)*
## P11c: the native Windows port (2026-10-29, D-62)
@@ -3727,6 +3758,7 @@ arm; `Listener`'s `IDLE_INSTANCES` pool (a mutant that makes it 1 passes
every test but `many_clients_at_once_all_connect`); `start.rs::host_env`'s
list. None of the Windows arms is reachable by `cargo mutants` on Linux:
they run on the Windows job only.
*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)*
## P11a steps 2 and 3: update, rollback, the Linux packages (2026-10-29)
@@ -3822,51 +3854,7 @@ covers the feature one; a case with a higher format number is a gap to
add), `swap_in`'s temporary-then-rename (`update_is_atomic`), `healthy`
(`failed_update_rolls_back`), `install_kind` (the marker test), and
`parse_version`'s rejections.
## P11a steps 4 and 6: the in-app accept, the viewer's notice, translocation (2026-11-05)
The accept is the host's lease intent `apply_update` (the name
`plan/served.md` 8.0.3 allocates); the viewer's names are
`Owner::update_waiting`, `Owner::check_for_updates` and `Owner::apply_update`,
and the agent's is `books.start`'s `update_waiting`.
- `crates/ledger-cli/tests/update.rs` (Linux):
`books_start_says_an_update_waits` — a quiet `books.start` has no
`update_waiting`; after the host stages a signed 9.0.0, `books.start`
names its version and staged time and says only the owner accepts it,
its next step says to tell the human, and no path appears in the answer;
`the_owner_accepts_a_staged_update_in_the_app` — accepting with nothing
staged is refused and changes nothing; once 9.0.0 is staged, the same
intent (`opener::ask_once_within` with `Intent::ApplyUpdate`, what the
viewer's `apply_update` sends) installs it, the program becomes the
release's bytes, the old version is kept under `updates/previous/`, and
an agent's bridge writes all through the host's restart onto it: every
account is in the chart exactly once.
- `crates/ledger-gui/tests/update_notice.rs`:
`the_menu_checks_for_updates_and_a_waiting_update_shows_a_notice_that_accepts`
(Books, Check for updates asks the owner and its words show; the standing
notice names the version; Update now asks the owner to apply it, and the
notice goes once it is installed) and
`books_outside_a_data_root_have_no_update_notice`.
- `crates/ledger-cli/tests/translocation.rs`:
`translocated_app_refuses_to_connect` — with the program's path standing
in for Gatekeeper's copy (`APPIMAGE` names a file under
`AppTranslocation`), `connect claude` is refused before a token is made
or a home is made, and the stand-in agent never runs; the same program at
an ordinary path connects. `crates/ledger-host/src/start.rs`:
`only_a_translocated_path_refuses_an_agent` (the marker is a path part,
not letters in a name).
Not checked here: the real Gatekeeper copy and the `.dmg` (needs a Mac,
the owner's trial); the Windows installer job is P11c's, not on this branch.
**For P12 (mutation targets, none run, D-33):** `update_waiting`'s
"newer than this program" comparison (a mutant that offers the running
version must die on `books_start_says_an_update_waits`'s quiet first
call), `staged_update_in`'s plausibility check (a staged version with a
path separator must not become a notice), and `translocation_refusal`'s
component comparison (a part that merely contains the word must not
refuse).
*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)*
## P5: the agent surface (2026-10-29)
@@ -3954,6 +3942,7 @@ equal-source refusals and the empty-reason refusals; the three
in `a_source_only_revision_is_journaled`); `books.start`'s fit order and
`reconciliation` split (`reconciled_through.is_some()`).
## P8: audit and attestations (2026-10-29)
*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)*
An audit run is a deterministic programme of checks over the books through
a day, filed as a JSON and a PDF workpaper; attestations are claims by a
@@ -4044,6 +4033,7 @@ export gate (`cpa_audit_standing`'s four conditions and its feature test,
the `refuse`/`warn` split, the reason's trim); `audit_items` in Needs
attention (the declared test, the attested set, the last-ended fiscal
year, the three year conditions).
*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)*
## P10: the re-audit runbook's mirror
@@ -4074,6 +4064,7 @@ sign-off) is the owner's and has no CI equivalent.
lines above it) and the scopes-line scan in `defect_20_*`. A mutant that
widens the window or drops the `ignore` check survives only if no ignored
or untested name is in the table; the table is the oracle.
*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)*
## P13 step 6: the specification in its own repository (2026-10-29)
@@ -4170,3 +4161,509 @@ brightening; `Session::resize`'s equality early return; the panel's
open/closed ids (a remembered size must not leak between them);
`Welcome::steps`' choice of `now` (each arm), the passphrase length and
equality gates, the `agent_later` flag.
*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)*
## P12: the checklists
The **For P12** paragraphs above (kept where they were written, with their
units' histories) consolidated into one checklist per crate, so the run
(step 2 of P12, D-33) walks a crate at a time. Each item names a target and
the mutants worth thinking about; its origin unit is in brackets. "None run"
holds for all of them: every survivor is killed by a test or recorded below
as equivalent, with its reason, when the run is made. The recipes are
`just mutants`, `mutants-store`, `mutants-import`, `mutants-config`,
`mutants-mcp`, and (P12 step 1) `mutants-host`, `mutants-package`,
`mutants-vault`, `mutants-cli`: each runs the crate's own tests and
`ledger-cli`'s end-to-end suites without the viewer, `-j 4` by default, one
run at a time, local only (D-59). `ledger-gui` has no mutation run (its two
musts are `tests/read_only.rs` and `tests/view_model.rs`). The `bookspec`
items are for that repository's own check (D-34 step 6). The whole diff to mutate: from `168494b` (main before P2 slice 3).
The fuzz targets P12 step 1 added: `fuzz_http_mcp_request` (arbitrary method,
path, headers and body into the in-process router, `ledger-mcp`'s
`local::fuzz_door`, behind its `fuzzing` feature: no token live for the book
is never a 2xx, a valid one never makes the door fail) and `fuzz_upload_put`
(bytes come in only if they are the declared ones, from the token the upload
was granted to, once). `fuzz_op_sequence::Op` gained the invoice, bill,
payment, settlement, write-off, void and draft-state ops and a control-check
invariant (control accounts equal their documents).
### ledger-core
- [P2 slices 3 to 8] `settings.rs` from scratch.
- [P3 odd cents] `kernel::apportion_cumulative` (length and range guards;
`checked_sub` for `checked_add`; the subtraction skipped);
`periods::allocate_cumulative` (the sign; `given` not negated for a loss;
`places` ignored; the exactness filter, zero products included);
`Ledger::members_regime` and `regime_where` (skip versus break at a close to
anything else; the order-free compare made ordered; `<=` for `<` in the date
filter; `given` added to the wrong member; the count's order);
`Regime::allocate` (the order); `Ledger::members_split` and
`close_period_to_steps` (the start check; the `posts` gate; the recorded
rule; the sweeps before the parts); `member_close_run`; `member_close_split`;
`reports::members_equity` (`allocate(C)` replaced by `given`; `places`;
"other"'s order); `k1::share_property`; `Currency::allocation_places`.
Accept counts for `ledger-core` and `ledger-import` are R-6.
- [P3 cash flow] `cashflow.rs` (`cash_flow`, `analyse`, `pairs`, `label`,
`class_of`, `reconcile`, `warnings`, `bill_splits`, `role_problems`, `foot`,
`on_behalf`); `kernel.rs`'s `take`, `apply_cash`, `entry_flows`,
`constructive`, `exchanged`. Likely equivalent, to adjudicate: `min` against
`max` in `take` where both bounds coincide; the kind order's tie-breaks.
- [P3 rename and defaults] `calendar::{fiscal_year_start_unclamped,
fiscal_year_unclamped, fiscal_year_start_on, period_defaults}`,
`PeriodDefaults::{statement, statement_range, as_of, calendar_year}`,
`PeriodKind::describe`, `Company::fiscal_year_containing`,
`ChartOfAccounts::set_name`, `Ledger::{add_account (its new-name checks),
add_control_account, check_account_name, rename_account,
check_rename_account, replay_rename_account, check_rename_reason,
check_tax_category_required, check_tax_category_kept}`,
`tax::{needs_category, account_needs_category, uncategorized_accounts,
on_form_4797}` and `tax_summary`'s Form 4797 section,
`Settings::require_tax_category`.
- [P3 parties] `kernel::party_target` (each branch, the `!= 0` and `< 0`
tests, the property and money clamps, the neither-when-nothing-counts test),
`kernel::reverse_target`, `Direction::{flip, of}`; `shares::target` (the
closing check, the reversal arm and its parity, members_only, the equity
filter and the retained-earnings exclusion, the property test, the own
sums); `shares::split` (the member-order tie-break, the cash shortcut, the
zero sign); `parties::{flow, root_original, money_accounts, card_accounts,
MoneyAccounts::for_parties}` (the negation of what_for, the walk's guard,
cards and financing as money, the gift-card exclusion); `activity_with`
(the target before the split, the skip of nothing-counts, the bucket by side
and basis, the overflow checks); `Activity::totals`; `Basis::parse`;
`Ledger::{set_party_creditor, check_set_party_creditor, creditors_of,
is_closing_entry}`. Expected equivalent: `Party::tin_last4`'s `or_else`
order swapped (TIN and W-9 facts never disagree, parties INVARIANT 2a,
enforced on replay).
- [P3 1099s] `form1099.rs` (`plan_decision`, `apply_decision`,
`check_new_decision`'s decided-before test, `in_force`/`in_force_before`
(`<` against `<=`), `vendor_1099_report`'s status branches (each `>=`, `<`,
`max(.,0)`, `min(.,0)`), the listing filter, the not-reportable and
undetermined routing, the R6 test (`< 0`, credit side, `refund_accounts` per
account type), `Roles::new` (which methods' accounts are financing, which a
card's), `split_share` (the floor corrections, the sign), `vendor_part`, the
unresolved fallback, `decisions_during`, `untagged_payments` (`> 0`, each
skip, each debit kind), `decision_changes`); `kernel.rs` (`payment_bucket`,
`split_payment`, `net_buckets`); `instruments.rs`,
`Ledger::check_add_instrument` (each refusal), `set_entry_instrument` (the
chain), the reversal step's copy, `set_vendor_w9_flag`.
- [P3 parties and 1099s landed together] `parties::card_accounts` (the
recorded-cards branch, the gift and other-instruments exclusions).
- [P3 asset basis] `kernel::basis_figures` (min for max, the sign of the gain,
purchase to a contribution); `Ledger::accumulated_posted` and
`assets::run_chain_entries` (`<=` for `<` on the entry and reversal dates,
the disposed-to-0 branch, the alternating sign); `live_depreciation_run`'s
reversal test; `check_depreciation_run`'s arms; the register filter
`acquired <= as_of` and `asset_acquired_date`'s min; `AssetBasis::check`,
`check_journaled`, `check_legacy_journaled`; the acquisition and in-service
date refusals; the disposal floor; `tax_capital::RunningSplit` (the sign, the
zero case, the snap to ten places, `unsplit`), `tax_capital::entry_parts`
(the member set, the date order, the reversal's take-back), `entry_property`
(the mismatch branch, the unknown sum); the open-item conditions
(`in_service`, the disposal window); the reconciliation's signs.
- [P3 surface review] `kernel::entry_movements` and `kernel::flows_of` (the
split of `entry_flows`; the same mutants as before; the earlier P3 lists
stand, `Ledger::depreciation_runs` was `depreciation_runs_ever`).
- [P3 projects] `check_set_project`'s arms (ended_on with active/paused, `<`
vs `<=` on the dates, the duplicate name's self-exception, case folding),
`ProjectFacts::check` (the link limit, whitespace, duplicates);
`kernel::unrecovered_cost` (`max`, the sign); `project_figures`' class
filter, 240-month window and closing-entry exclusion; `project_entries`'
normal-side sign and balance-sheet zero; `check_link_to`'s feature check.
- [P3 project snapshot] `contribution_margin_pct`'s `<=` and rounding
strategy; `project_figures`' opening net and running sum;
`profit_and_loss_counted`'s counts; `Standing::of`'s order.
- [Documents take a project] `document_parts` (first-appearance order, the
`contains` test); `ensure_document_lines`' `any`;
`parse_document_lines`' arms (null, absent, out of `u32`);
`document_projects_rule`'s branch.
- [P4b S1 A] `stands_on`'s count and in-order `take_while`; `doc_state`'s
movement arms and `overdue_days`; `doc_status`'s order and its Overpaid and
Unattached arms; `check_hand_line`/`check_control_lines` (the
reversal-mirror exemption); `control_role_of`'s two clauses and the
exemption's even-depth test; `check_assign_role`'s SCENARIO 6 and its
`controls` filter; `standing_payments`' `!reversal`; `holds_role`'s type
check; `bounds_kept`'s `>`; `payment_room`'s `from`; `tag_replayed`'s side,
amount match and one-line rule; `refresh_doc`'s settled sum; the aging's
`<= 0`; `check_document_reversal`'s date check; `control_check`'s chain
netting.
- [P4b S1 B] `PaymentDetails::check` (the newline test, the 200-character
bound, a blank reference as none); `PaymentDetails::description` (the
" (ref R)" suffix, the trim, a blank reference leaving the base).
- [P4b S5] `Ledger::mark_sent`'s refusals (unknown invoice, a send dated
before it, `DocumentVoid`, empty `to`, each of the three `check_text` calls;
the note's check is stricter than the plan, to keep a full TIN out of the
journal); `SentVia::parse`'s arms and `other:` prefix; `SendKind::parse`;
`last_sent`'s `max`.
- [P4b S6] `kernel::settle`'s length check, each negative check, the
fee-above-cash test and both checked adds; `Ledger::settlement_lines`'s
kind/party checks, duplicate-document check, per-application `cash +
discount` bound, discount window (`<= max`, `<= by`, "has a reason"),
asset-bill loop, fee-on-bill refusal and row order (payment lines,
discounts, fee, cash); `check_discount`'s `within` conjunction and `because`
arms; `replay_*_payment_at`'s line/amount/account/side match and the discount
line's tag; `bills_due`'s `through` filter, discount-window test and
running-total grouping; `aging_documents`'s outstanding filter and bucket.
- [P4b S7] `Ledger::write_off_steps`' cash-basis comparison
(`eq_ignore_ascii_case`, the early return), order of checks (amount before
the day, the day before the room), the `amount > room` bound and its `room <
owed` message arm, the `WriteOff` tag, `least_owed_from`'s `max` and its
`min` over later days (shared with `payment_room`),
`replay_invoice_write_off`'s movement, `doc_state`'s `written_off` arm.
- [P4] `evidence::requirement`'s exemption arms (reversal, closing, schedule
month, depreciation run, disposal origin, payroll remittance origin, a
tagged document line, a payroll run) and their order, the split-group branch
taken only for a `Draft`, `Policy::reason_for`'s three comparisons (required
account, `<=` the threshold, every code exempt); `Ledger::origin_exemption`;
the replay arms that note `AssetDisposal` and `PayrollRemittance`;
`Ledger::discard_draft`'s empty-reason and posted refusals and the side
tables it clears; `set_draft_state`'s `check_text`; `stamp_unstamped` and
`expect_posted_seq` (a mutant that stamps the wrong line shows in
`posted_seq_is_the_posting_lines_seq`).
- [P6] `ledger-core::evidence`: `checked`'s trims, the zero number, the
negative amount and the empty-annotation refusal; `next_receipt_number`'s
`saturating_add`.
- [P7] `parties.rs` `tin_gap` (the space-run arm, so four spaces count once;
`count <= MAX_TIN_GAP`; the invisible skip; the `grouped` arm), `tin_space`,
`grouped_tin_separator` (each of `,`, `(` and `)`), `grouped_tin_runs` (each
shape, the `n > 0` join, the nine-digit check), `full_tin_runs`'s
`extend(grouped_tin_runs(..))`.
- [P3 IIF] `form1099.rs`'s `through` bound; `parties.rs` `tin_separator`'s
arms and `tin_invisible`, and the gap that doesn't count them (with [P7]'s
scanner items above).
- [P13] the bookspec loader's party, payee, decision and payment-method
setup; its exact-spelling filters; `check_k1`'s `cash_accounts` check;
`documented_tools` (its own sample checks it); `may_refuse` for write-offs.
### ledger-store
- [P2 slices 3 to 8] its 34 triaged survivors; slices 7 and 8 from scratch:
`backup.rs`, `root.rs`.
- [P3 odd cents] `close_to_from_journal` and `close_period_to` (the tag
mapping; `since_seq`'s parse; a tag that isn't text; the feature
declaration); `format::declared_knowing`.
- [P3 rename and defaults] `Store::{ensure_feature, ensure_features,
journal_settings (its feature), add_account, add_account_with_tax,
journal_account_added, ensure_control_account, rename_account,
set_account_tax, journal_account_tax}`, the `account.renamed` replay and
projection arms, `format::setting_feature`, `ops::report_columns`.
- [P3 parties] `Store::{set_party_creditor, ensure_feature}`.
- [P3 1099s] `form1099.rs` (each decode check, `newer_content`), `store.rs`
(`ensure_feature`, `set_vendor_1099`'s unchanged path, `set_vendor_w9_flag`,
`add_instrument`, `set_entry_instrument`'s no-op, the replay and projection
arms, `sqlite_reversal_chain`).
- [P3 format upgrade] `format.rs` (`current`, `upgrade_to`, `adds`,
`require_format`, `require_feature`), `store.rs` (`create_in_format`,
`ensure_format`, `ensure_feature`, `upgrade_format`), `root.rs` (adoption's
upgrade and its verify), `backup.rs` (`prove_archive`, `held_files`).
- [P3 asset basis] `asset_basis_from_journal` (newer vs corrupt); the
projection loader's two key sets.
- [P3 projects] `project_set_of` (newer vs corrupt); `Store::add_project`'s
"facts beyond the default" test (one line vs two); `set_project`'s unchanged
short-cut; `subject_feature`.
- [Documents take a project] `add_document_parts`' tag index.
- [P4b S6] the `documents_v2` condition (more than one application, any
discount, a fee) and the `line`/`discount_line`/`fee_line` arithmetic.
- [P4b S7] `Store::write_off_invoice`'s evidence arms (the `check_linkable`
call, the already-linked-to-the-invoice check, the blob-before-journal
order, the `File`/`Existing` link steps) and the feature gate.
- [P6] `attach`'s live-record lookup and link arms, `extract_and_journal`'s
text/blob branch and status mapping, `extraction_for`'s triple (attachment,
extractor, version), `withdraw_attachment_as`'s `duplicate_of` arm and
feature check, the `verify` text-blob loop, the projection arms' conflict
keys.
- [P8] `Store::figure_head` (the `NOT_FIGURES` list: a type wrongly in it
lets a stale audit stand, a type wrongly out of it makes an audit stale at
its own filing), `find_audit_run`'s four equalities, `record_audit_run`'s
idempotence return, `attest_receipt`'s amount rule (the debit total or any
one line) and date equality, `run_for_period`'s `through_seq == head` and
`through >= period.end`; `Integrity::check`'s intact-blob set and
`Store::disk_check`'s snapshot load and `Unreadable` arm [U0b].
### ledger-import
- [P3 IIF and as-of] `iif.rs` (each `breaks` arm, the control, quote and
`None` arms of `each_piece`, the deunicode post-mapping, `segment`'s colon,
trim, cut and "?" fallback, `tag`, `with_tag`'s room, the clash loop's depth
bound, rounds and tag, `journal_to_iif`'s clash and length refusals, the
number rules, the memo cut, each `changes` comparison and its order,
`warnings`' grouping, order and masks, `shown`, DOCNUM, `EOL`); `pdf.rs`'s
fallback font; `iif_accounts`' type filter on parents and `apart_from`,
`parents_first`, the `AtTopLevel` change, the TIN note's condition and
`receivable_payable`; `fonts.rs`.
- [P3 cash flow] `report::cash_flow`.
- [P6] `extract`: the caps' comparisons (`MAX_BYTES`, `MAX_PAGES`,
`MAX_TEXT_BYTES`, the `cap_text` boundary walk), `extract`'s order (PDF by
type or magic, XLSX by type or extension, then plain text), `found`'s
blank-text arm; `pdf_lines`' page `take`, baseline rounding and
left-to-right sort; `page_png`'s scale floor and page bounds; `xlsx_text`'s
shared-string index, the `t="s"`/`inlineStr`/`b` arms and the sheet-name
filter.
### ledger-config
- No unit recorded a target for this crate since its 2026-10-14 run (55 caught
of 55 viable); the run (`just mutants-config`) confirms it still holds.
### ledger-mcp
- [P2 slices 3 to 8] `local.rs` (45 survivors) and `server.rs` (31); slices 7
and 8 from scratch: `backup.rs`, `tools/backup.rs`, `local.rs`, `admin.rs`.
- [P3 odd cents] the MCP's `close_period` and `list_periods` fields.
- [P3 IIF and as-of] `server.rs` (the iif report, range and as_of checks,
`warnings`, the package note).
- [P3 cash flow] `cash_flow_out`, `cash_flow_csv`, `export_cash_flow`,
`settings_problems`.
- [P3 rename and defaults] `show` (the option refusals, the resolve, the
`shown` wording), `show_report_dates`, `rename_account` (its notes),
`add_account`, `import_chart_csv` (both loops), `profit_and_loss_by_class`'s
range, `export_journal`'s period, `described`, `uncategorized_warning`,
`created_uncategorized`, the digest's `uncategorized_accounts`,
`handbook::period_rule`.
- [P3 parties] the server's `parse_basis`, `party_activity`,
`entry_parties_out` (direction, cash, in_kind) and `update_party`'s
creditor_of diff.
- [P3 1099s] `tools/instruments.rs` (`decision_in`'s refusals,
`filed_year_warnings`' closed and due tests, `resolve_instrument`,
`add_instrument`'s warnings), `server.rs`'s `set_vendor_1099` (the W-9-only
path) and `set_w9` (the feature declared first, only when the flag changes).
- [P3 parties and 1099s together] the server's `vendor_1099` (`through`'s
year check, the unknown-code check, the accounts passed) and `update_party`'s
feature declaration.
- [P3 format upgrade] `backup.rs` (`upgrade_format`,
`UpgradeFailed::parts`), `local.rs` (`upgrade_format`, the List's
`format_upgrade`, `waiting`), `server.rs` (`with_store_mut`),
`tools/kept.rs` (the first next step, `format_upgrade`), `admin.rs`.
- [P3 asset basis] the MCP `aliased` logic (both given and differing).
- [P3 projects] the decision `projects` omit-when-empty; `late_tag_note`'s
`is_over`; `remember_footer`'s threshold.
- [Documents take a project] `document_notes`' dedup and its `invoice` flag;
`on_spec_invoice_note`'s kind test; `document_line_json`'s `if let`.
- [P4b S1 B] the server's `payment_details` (id or label resolution, an
unknown instrument refused); `payment_out` (as of the end of the books,
`remaining` from outstanding, `status` from `key()`); `doc_ref` (trim and
case, the two kinds, anything else refused); the `void_document` handler
(the status it answers, `map_or("void", ..)` after the day);
`control_check_out` (`agrees` as zero difference and no unattached line and
nothing needing attention, each field's mapping, the invoice/bill kind);
`control_problems` (one message per unattached line and per document, both
loops); the digest that includes it and the `verify` check that refuses a
nonzero difference.
- [P4b S2] the `documents` handler's status arms (`"all"`; `"open"` as
`!st.outstanding.is_zero() && key != "void"`; `"overdue"` as
`st.overdue_days > 0`; `key == other`), the party compare, `due_by`'s `>`,
the `(due, id)` sort, the `outstanding_total` sum; the `document` handler's
`l.date <= as_of` timeline filter, `stands`/`reversal` from `stands_on`,
`movement_key`'s arms, `parse_doc_ref`'s split, parse and kind;
`DocStatus::key`'s arms.
- [P4b S4.5] the handbook's `documents_v2_rule` both arms and the server's
`documents_v2` feature test.
- [P4b S5] the handler's `kind` default (`original` when `sends` is empty,
`reminder` after), its `to` fallback (bill-to, then party, then refused) and
`by` fallback; `sent_via`'s arms and empty fallback; `send_kind`'s error
arm; `invoice_id`'s `invoice:` strip; the replay reader's `newer_content`
arms for an unknown channel or kind.
- [P4b S6] `aging_csv`'s tie-out columns and `aging_statement`'s totals.
- [P4b S7] the handler's amount default, exactly-one evidence check, and the
preview's clone.
- [P6] `evidence`: `find_attachment`'s exactly-one selector, the hex check and
the text length floor; `consolidate_duplicates`' grouping (`< 2`), the
keep/dups split, the evidence `left == 0` gate, the keep `withdrawn` test,
the unlink dedup and the `withdrawn` count; the annotation amount/date parse
and the next number; `get_receipt`'s view arms, the 8 KiB cut, the 64 KiB
hand-over threshold and `page - 1`.
- [P7] `tin_files::tin_shaped`'s comma in each rendering and
`holds_printed_tin`'s comma join; `access::masked`'s string arm. A deleted
arm leaves a TIN the old scanner passed readable or unmasked; the truth
table and the property catch it.
- [P9a] `Isolation::parse`'s `same_user`/`advisory` and
`service_user`/`enforced` arms; `Isolation::label`'s two strings;
`start_out`'s `self.isolation.label()` (a mutant restoring a literal must
fail the two-door test); `local::tunnel_address`'s `is_loopback` test (must
fail `the_tunnel_door_binds_loopback_only`, in both halves); the `--http`
arm's `args.peek()` guard and default `127.0.0.1:7411`; `tunnel.is_some()`
and the bind failure path (ready after the try; a failed bind not fatal);
`open_with`'s `isolation` into `Served` and `BooksServer::new`'s copy (must
fail `isolation_label_is_enforced_under_the_unit`).
- [P4] `Preview::operation` (the keys it drops: `confirmed`, `preview`,
nulls) and `Preview::check`'s three arms (missing, equal, different);
`post_entry`'s draft payload (each field); the digest's `since_seq`
comparison (`>`), its `as_of` bound, the `zero_net_accounts` branch and the
`never_reconciled` split; `report_status`'s three open conditions; the
open-drafts refusal's `<= as_of` and its decision.
- [P5] `next_steps::apply`'s three branches (an error, a result without an
object, the normal path) and the `mutating` test that gives `head`;
`tool_steps`'s confirm branch (`!confirmed` and a `preview`), the
`receipt_missing` pair before it, each tool's arm; `attention_steps`'s
subject match (entry against invoice) and its `Problem` severity for
`required`; `subject_of`'s precedence; `guards::from_refusal`'s structured
and worded branches and `Guard::remedy_tool`'s table; `prompts::checked`'s
token test and each `Check` shape (month length and calendar, year length,
the word list); `prompt_get`'s extra-argument and missing-argument
refusals; `describe::subjects`'s per-prefix id choice, `describe`'s
`settings.set` key list and TIN mask, `history`'s `>` on `after_seq`, its
`limit` cut and `more`; `check_convention_source`'s three equalities,
`check_decision_source` and `check_open_item_source`'s equal-source and
empty-reason refusals; the three `apply_*_source` arms (a mutant that also
bumps `revision` or `seq` shows in `a_source_only_revision_is_journaled`);
`books.start`'s fit order and `reconciliation` split.
- [P8] `audit::statements`' order of checks and `sort`; each `check_*`
predicate's comparison (`check_trial_balance`'s `balanced`,
`check_articulation`'s `!=`, `check_split`'s `> unit`, `check_register`'s two
conditions, `check_cash_flow`'s three arms, `check_control`'s `difference`,
unattached and `Overpaid` arms); `evidence`'s three arms (Required, the
reversal exemption and the policy test that lists it, the plain exemption)
and its receipt-set lookup; `closed_net`'s sign for revenue and expense;
`AttestationBar::counts` (each of the four conditions, `>=`, the `full`
branch, the `disagrees` early return) and `parse`; `Scope::full_bar` and
`serves_handoff`; `integrity_findings`'s eight arms; `backup_findings`'
three conditions; the export gate (`cpa_audit_standing`'s four conditions
and its feature test, the `refuse`/`warn` split, the reason's trim).
- [P10] the `DEFECTS` table's file/name lookup (a `starts_with` on
`fn <name>(`; the `#[test]`/`#[ignore]` window of eight lines above it) and
the scopes-line scan in `defect_20_*`: a mutant that widens the window or
drops the `ignore` check survives only if no ignored or untested name is in
the table, so the table is the oracle.
### ledger-package
- [P3 IIF and as-of] `iif.rs` (`<=` in the as_of filter, the closes filter,
the FIXASSET date filter); `package.rs` (the cash and financing resolution,
the register's presence, the 1099 `through`, the README's IIF line, closes
sentence, warnings block and 1099 wording).
- [P3 rename and defaults] `statement_range`, `as_of_date`, `pnl_statement`,
`bs_statement`, `cash_flow_statement`, `year_earnings`, `build_package`'s
fiscal year.
- [P3 1099s] `vendor_1099.rs` (the CSV columns, every note's guard,
`may_file`, `missing_w9`'s suffix), `package.rs` (tin_gaps, the README line).
- [P3 parties and 1099s together] `Settings::with_accounts` (each list's
fallback).
- [P4b S4.5] `invoice::payment_methods` (the `qr` flag read, `link` appended
as a last line, the QR only when a link exists); `page_of`'s
snapshot-else-party choice for the bill-to, the contact, address, phone,
email order of the bill-to lines, the seller email's snapshot-else-company
fallback, the blank-footer filter; `invoice_document`'s stamp arms and
`InvoiceVersion::Current { as_of }`.
- [P4b S3] `InvoiceStamp::text`'s five arms and `template_data`'s
`stamp_draft` test (`== Draft`); `invoice_document`'s stamp match (Void, then
Paid with `last_paid`, then `overdue_days > 0` before PartiallyPaid, then
none); the settled loop (`movement == Payment`, `!reversal`, `l.date >
as_of`, `!stands_on`, the negated amount, the method and " · ref " suffix
parsed back out of the description, `last_paid`); the re-created-copy branch
(`number: format!("Invoice {id}")`, `terms: None`, the `note` naming
`today`); `dollars`' sign handling and `format_amount`'s parenthesized
negatives; `template_data`'s no-lines refusal. Expect survivors in
`PartiallyPaid` (no sample is part paid and not yet due) and `Draft` (only
S4's preview reaches it) until a test asserts them.
- [P4b S4.5] `invoice_pdf::dollars` (the negative arm) and `unit_price`
(`scale() <= 2`, the exact arm), `quantity_detail`'s both-or-neither match,
`note_text`'s three arms (re-created, both, note only).
- [P4b S5] attention: `invoice.number.is_some()` (the legacy exemption),
`Open | Partial`, `sends.is_empty()`, the `checked_add_days` `unsent_days`
comparison (`>`), `overdue_days > 0`, the `sends.max()` last send, the
`reminder_days` comparison (`>`), each item's target (`Target::Invoice`) and
its `books.mark_sent` step args.
- [P6] attention: the extraction `seq` comparison and the `Failed` filter, the
owed-account name test, the credit side and the `settles_entry` skip.
- [U0b] `attention`: `origin`'s arm order (a reversal before its schedule, a
bill before its payment) and `entry_refs`' keys; `missing_receipt`'s
conjunction (the `Journal entry | Recurring` origin, `reversal_of` none, no
receipts, the policy); the `> 3` combine and its `try_fold`; `w9_missing`'s
three clauses (no W-9, files a 1099, the vendor not marked on file) and
`files_1099`'s `any`; `paid_this_year`'s `max` (today) and `min` (Dec 31) and
its `paid - received`; `depreciation_due`'s `in_effect` filter,
`period_month` (`1..=12`) and `!is_zero`; `period_month`'s split; the
severity arms (undetermined 1099, refuse tax category, parties require);
`dollars`' thousands grouping and negative-zero test.
### ledger-host
- [P2 slices 3 to 8] pass 2; `commands.rs`.
- [P3 format upgrade] `host.rs` (`consent`, `consented`), `commands.rs`
(`consent_now`, `upgrade_format`, `offer_upgrades`, `offer_upgrade`,
`upgrade`, `book_line`, `upgraded_lines`, `upgrade_explained`,
`ASKED_UPGRADES`).
- [P9a] the host's `server_flags` extension and `parse_args`' passthrough of
`--isolation` and its optional `--http` value.
- [P11a step 0] `tokens.rs`'s `os` modules are not reachable by mutation
testing (they need the real OS store); the `Items` logic they share is,
through the file store.
- [U0e] `tokens.rs`'s `Keeper::set` (the temporary file's mode and the
rename: a mutant that writes the final file directly or wider must die on
`a_token_file_is_owner_only`), `Keeper::get`'s `NotFound` arm (must die on
`an_unreadable_token_file_is_an_error_never_no_token`), `Keeper::delete`'s
`NotFound` arm, `Account::key`'s three parts
(`an_account_names_the_root_by_hash_and_the_book_and_token`);
`bridge.rs::token_file_refusal`'s `holds_token` guard.
- [P11c] `winsec::is_private`'s three refusals (owner, missing DACL, an allow
entry for anyone else) and the ACE types it skips; `ipc::Endpoint::new`'s
hash input (a mutant that drops the lower-casing or the prefix strip splits
one root into two pipes: found only by two processes spelling the path
differently); the Windows `StdStream::read_some`'s deadline comparison and
`BROKEN_PIPE` arm; `Listener`'s `IDLE_INSTANCES` pool (a mutant making it 1
passes every test but `many_clients_at_once_all_connect`);
`start.rs::host_env`'s list. None of the Windows arms is reachable by
`cargo mutants` on Linux: they run on the Windows job only.
- [P11a steps 2 and 3] `update.rs`'s `verify` (a mutant that drops the sha or
signature check must die on `unsigned_update_refused`'s sha and key cases),
`refuse_if_consented`'s two comparisons (the feature one is covered by
`rollback_refuses_after_a_book_consented_to_a_newer_format`; a case with a
higher format number is a gap to add), `swap_in`'s temporary-then-rename
(`update_is_atomic`), `healthy` (`failed_update_rolls_back`),
`install_kind` (the marker test), `parse_version`'s rejections.
- [P11b] `commands::make_books_home_in` (the `!path.exists()` test that keeps
a person's `AGENTS.md`; the merge that dedupes the allow rule;
`BookId::parse` before any path is joined); `find_agent_in` (the order PATH
then the installer folders; `is_program`'s mode test); `start_agent_in` (the
`Missing` arm); `first_run_state`'s three fields; `set_first_passphrase`'s
already-set refusal.
### ledger-vault
- No target of its own was recorded by any unit. The recipe `mutants-vault`
runs the crate's `tests/vault.rs` and the `ledger-cli` suites that reach the
vault (`spec_vault.rs`, `tin_files.rs`); whatever survives is judged in the
run.
### ledger-cli
- [P2 slices 3 to 8] `ledger-cli` from scratch (the argument handling, the
server and bridge modes). The run is `just mutants-cli`.
- [P9a] the `--isolation` and `--http` flags as the executable passes them on.
### Not mutated
- [P11b] `ledger-gui`: `dock::key_bytes` (the modifier parameter's arithmetic,
the Ctrl-letter byte, the app-cursor switch, the Alt escape), `dock::color`'s
cube and gray ramps and its bold brightening, `Session::resize`'s equality
early return, the panel's open/closed ids (a remembered size must not leak
between them), `Welcome::steps`' choice of `now` (each arm), the passphrase
length and equality gates, the `agent_later` flag. The viewer has no
mutation run (owner, 2026-10-18); these are for the viewer's own tests.
- [P3 rename and defaults, P3 1099s, P3 IIF, P4b S2 to S4.5, P7] the viewer
items (`view_model.rs`, `show.rs`, `owed_to_member`, `party_what_for`,
`share_text`, `Filter::keeps` and its sorts and labels, `document_of`,
`details`, `invoice_name`, `issued_copy`, `viewer discount_by` and
`days_overdue`, `menu.rs`'s note, `theme.rs` `font_definitions`): covered by
`view_model.rs`, `show.rs` and `read_only.rs`, not mutated.
### bookspec (its own repository since P13 step 6)
- [P13] `rules::build`'s area ordinals; `RuleId::parse`'s prefix, area,
digit, leading-zero and zero checks; `rule_for_refusal`'s mapping arms;
`schema::is_exact_decimal`, `is_date`, `is_month`, `is_month_day`,
`check_amount`'s positivity; `Example::validate`'s closed-list arms, the
one-kind rule, `reverses < n`, shares summing to 1, `after` bounds, the
command's accepted-or-refused exclusivity, the `only` table;
`Query::is_complete`'s membership; `Refusal::of_reason`'s fallback; the
runner's `compare`, `Outcome`'s mapping and `run`'s "claimed but no example"
arm; the adapter's `event_of`, its `depreciation_schedule` month span and
every `answer_*` figure against the loader's same check (the two must agree
on all examples).
- [P13 steps 4 and 5] the adapter's `refusal` arms (`UnbalancedEntry`,
`NegativeAmountLine`, `ZeroAmountLine`, `FinerThanMinorUnit`,
`ParentTypeMismatch`, `EntryAlreadyReversed`, `ClosedPeriod`), each pinned by
the example that expects it, and the `money` closure's positivity test;
`rule_for_refusal`'s new arms; `check_amount`'s `positive` flag; `derive`'s
`refusal_justified` arms, `Model::post`'s close, `signed`'s date window and
`closings` flag, the accounting-equation check; the spec test's pin search.
+934 -1
View File
File diff suppressed because it is too large. Load diff
+18
View File
@@ -24,6 +24,10 @@ path = "../crates/ledger-store"
[dependencies.ledger-import]
path = "../crates/ledger-import"
[dependencies.ledger-mcp]
path = "../crates/ledger-mcp"
features = ["fuzzing"]
[dependencies.ledger-config]
path = "../crates/ledger-config"
@@ -105,3 +109,17 @@ path = "fuzz_targets/fuzz_settings_replay.rs"
test = false
doc = false
bench = false
[[bin]]
name = "fuzz_http_mcp_request"
path = "fuzz_targets/fuzz_http_mcp_request.rs"
test = false
doc = false
bench = false
[[bin]]
name = "fuzz_upload_put"
path = "fuzz_targets/fuzz_upload_put.rs"
test = false
doc = false
bench = false
+169
View File
@@ -0,0 +1,169 @@
// Fuzz target: the books server's HTTP door takes arbitrary requests.
// Method, path, headers and body are all untrusted; the door is the very
// router the server mounts (ledger-mcp local.rs `router`), in-process over a
// real book, with no socket (`fuzz_door`). Whatever arrives: the door
// answers (no panic, no hang), and a request that carries no token live
// for the book is refused 4xx and never run (INVARIANT 8, local.rs); one
// that carries it never makes the door fail (no 5xx on the MCP route).
#![no_main]
use arbitrary::Arbitrary;
use ledger_mcp::local::fuzz_door::FuzzDoor;
use libfuzzer_sys::fuzz_target;
use std::cell::RefCell;
#[derive(Arbitrary, Debug)]
enum Auth {
None,
Valid,
Other,
/// `Authorization` with whatever text.
Raw(Vec<u8>),
}
#[derive(Arbitrary, Debug)]
enum Body {
Raw(Vec<u8>),
/// A JSON-RPC message the server could understand.
Rpc {
method: u8,
id: u8,
params: String,
},
/// `tools/call` of a name picked from a few real ones, arguments as text.
Call {
tool: u8,
arguments: String,
},
}
#[derive(Arbitrary, Debug)]
struct Request {
method: Vec<u8>,
verb: u8,
route: u8,
book: u8,
suffix: String,
auth: Auth,
session: Option<String>,
content_type: u8,
accept: bool,
extra: Vec<(Vec<u8>, Vec<u8>)>,
body: Body,
}
const METHODS: [&str; 6] = ["POST", "GET", "DELETE", "PUT", "OPTIONS", "PATCH"];
const RPC: [&str; 8] = [
"initialize",
"notifications/initialized",
"tools/list",
"ping",
"resources/list",
"prompts/list",
"tools/call",
"logging/setLevel",
];
const TOOLS: [&str; 6] = [
"books.start",
"books.trial_balance",
"books.draft_entry",
"books.begin_upload",
"books.settings",
"books.handbook",
];
thread_local! {
/// The door, and how many requests it has had: a fresh book every so
/// often, because sessions and the access log only grow.
static DOOR: RefCell<Option<(FuzzDoor, u32)>> = const { RefCell::new(None) };
}
fn clean(s: &str) -> String {
s.chars()
.filter(|c| c.is_ascii_alphanumeric() || "-_.".contains(*c))
.take(40)
.collect()
}
fuzz_target!(|r: Request| {
DOOR.with(|cell| {
let mut cell = cell.borrow_mut();
if cell.as_ref().is_none_or(|(d, n)| *n > 3000 || d.sessions() > 500) {
*cell = Some((FuzzDoor::new(), 0));
}
let (door, n) = cell.as_mut().unwrap();
*n += 1;
let method: Vec<u8> = if r.verb % 4 == 0 {
r.method.clone()
} else {
METHODS[r.verb as usize % METHODS.len()].as_bytes().to_vec()
};
let book = match r.book % 4 {
0 | 1 => "acme".to_string(),
2 => "nobody".to_string(),
_ => clean(&r.suffix),
};
let path = match r.route % 6 {
0..=2 => format!("/b/{book}/mcp"),
3 => format!("/b/{book}/u/{}", clean(&r.suffix)),
4 => format!("/b/{book}/a/{}", clean(&r.suffix)),
_ => format!("/b/{book}/x/{}", clean(&r.suffix)),
};
let mut headers: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
match &r.auth {
Auth::None => {}
Auth::Valid => {
headers.push((b"authorization".to_vec(), format!("Bearer {}", door.bearer()).into_bytes()));
}
Auth::Other => {
headers.push((b"authorization".to_vec(), format!("Bearer {}x", door.other_bearer()).into_bytes()));
}
Auth::Raw(v) => {
headers.push((b"authorization".to_vec(), v.clone()));
}
}
if let Some(s) = &r.session {
headers.push((b"mcp-session-id".to_vec(), clean(s).into_bytes()));
}
headers.push((
b"content-type".to_vec(),
["application/json", "text/plain", "application/octet-stream", ""][r.content_type as usize % 4]
.as_bytes()
.to_vec(),
));
if r.accept {
headers.push((b"accept".to_vec(), b"application/json, text/event-stream".to_vec()));
}
headers.extend(r.extra.iter().take(4).cloned());
// The door's own rule, asked directly: any header (the authorization
// one, or a smuggled extra) can carry a token live for the book.
let live = door.carries_a_token(&headers);
let body = match &r.body {
Body::Raw(b) => b.clone(),
Body::Rpc { method, id, params } => {
let params = serde_json::from_str::<serde_json::Value>(params).unwrap_or_default();
serde_json::json!({"jsonrpc": "2.0", "id": id, "method": RPC[*method as usize % RPC.len()], "params": params})
.to_string()
.into_bytes()
}
Body::Call { tool, arguments } => {
let args = serde_json::from_str::<serde_json::Value>(arguments).unwrap_or_default();
serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": TOOLS[*tool as usize % TOOLS.len()], "arguments": args}})
.to_string()
.into_bytes()
}
};
let Some((status, _)) = door.send(&method, &path, &headers, body) else {
return; // not an HTTP request at all
};
if !live && book == "acme" && path.ends_with("/mcp") {
assert!((400..500).contains(&status), "no token, yet {status} for {path}");
}
if live && path.ends_with("/mcp") && book == "acme" {
assert!(status < 500, "a valid token made the door fail: {status}");
}
});
});
+327 -23
View File
@@ -2,27 +2,112 @@
// balanced pairs, foreign-currency lines, periods, and closes, with
// amounts built from raw decimal bits (huge, tiny, odd scales). Refusals
// are fine; panics, hangs, and broken books are not. After any sequence:
// the trial balance balances, every posted entry validates, and no draft
// sits in a closed period (the Lean invariant Inv).
// the trial balance balances, every posted entry validates, no draft sits
// in a closed period (the Lean invariant Inv), and the control accounts
// equal their documents (control_check's difference is zero, B-38).
//
// The documents (invoices, bills), their settlements and payments, write-offs
// and voids, and the draft-state ops (P12) run on the Ledger the store wraps:
// the store adds the journal, the engine is the state machine fuzzed here.
#![no_main]
use arbitrary::Arbitrary;
use chrono::NaiveDate;
use ledger_core::{AccountType, Currency, EntryStatus, Ledger, Money, Side};
use ledger_core::arap::{DocRef, DocumentLine};
use ledger_core::{
AccountType, Application, Currency, DraftState, EntryStatus, Ledger, Money, PaymentDetails,
SettleParams, Side,
};
use libfuzzer_sys::fuzz_target;
use rust_decimal::Decimal;
#[derive(Arbitrary, Debug, Clone, Copy)]
enum Op {
AddAccount { code: u8, typ: u8, placeholder: bool },
CreateEntry { day: u8 },
AddLine { entry: u8, account: u8, debit: bool, amount: Amount },
Pair { entry: u8, debit: u8, credit: u8, amount: Amount },
AddLineFx { entry: u8, account: u8, debit: bool, foreign: Amount, rate: Amount },
PostEntry { entry: u8 },
ReverseEntry { entry: u8, day: u8 },
CreatePeriod { start: u8, len: u8 },
ClosePeriod { period: u8 },
AddAccount {
code: u8,
typ: u8,
placeholder: bool,
},
CreateEntry {
day: u8,
},
AddLine {
entry: u8,
account: u8,
debit: bool,
amount: Amount,
},
Pair {
entry: u8,
debit: u8,
credit: u8,
amount: Amount,
},
AddLineFx {
entry: u8,
account: u8,
debit: bool,
foreign: Amount,
rate: Amount,
},
PostEntry {
entry: u8,
},
ReverseEntry {
entry: u8,
day: u8,
},
CreatePeriod {
start: u8,
len: u8,
},
ClosePeriod {
period: u8,
},
CreateInvoice {
day: u8,
terms: u8,
amount: Amount,
},
CreateBill {
day: u8,
terms: u8,
amount: Amount,
},
/// A payment on a document (mod the documents) from the bank or a
/// fuzzed account, with a reference or not.
Pay {
doc: u8,
day: u8,
amount: Amount,
from: u8,
reference: bool,
},
/// One payment settling two documents, the first with a discount.
Settle {
a: u8,
b: u8,
day: u8,
cash: Amount,
discount: Amount,
},
WriteOff {
doc: u8,
day: u8,
amount: Amount,
},
VoidDocument {
doc: u8,
day: u8,
},
SetDraftState {
entry: u8,
awaiting: bool,
note: u8,
},
DiscardDraft {
entry: u8,
},
}
/// A decimal from raw parts: any mantissa up to 96 bits, any scale 0-28.
@@ -42,7 +127,13 @@ impl Amount {
// Mostly ordinary money, so entries balance and post.
return Decimal::new(i64::from(self.lo % 1_000_000), 2);
}
Decimal::from_parts(self.lo, self.mid, self.hi, self.negative, u32::from(self.scale % 29))
Decimal::from_parts(
self.lo,
self.mid,
self.hi,
self.negative,
u32::from(self.scale % 29),
)
}
}
@@ -63,34 +154,85 @@ fn day(d: u8) -> NaiveDate {
fuzz_target!(|ops: Vec<Op>| {
let mut l = Ledger::new(Currency::USD);
let mut entries: Vec<u64> = Vec::new();
let mut docs: Vec<DocRef> = Vec::new();
// The books' fixed parts: the control accounts documents post to, a bank,
// a customer and a vendor.
let _ = (
l.add_control_account(&ledger_core::RECEIVABLES),
l.add_control_account(&ledger_core::PAYABLES),
l.add_control_account(&ledger_core::BAD_DEBTS),
);
let bank = l
.add_account("1900", "Bank", AccountType::Asset, None, false)
.ok();
let customer = l.add_customer("Fernhill Labs").ok();
let vendor = l.add_vendor("Tobin Harlow Supply").ok();
let doc_line = |a: Amount| {
vec![DocumentLine {
description: "work".into(),
amount: a.get(),
class_id: None,
account: None,
quantity: None,
unit_price: None,
}]
};
let pick = |v: &[u64], i: u8| v.get(i as usize % v.len().max(1)).copied();
for op in ops.into_iter().take(200) {
let accounts: Vec<u64> = l.chart().all().map(|a| a.id).collect();
match op {
Op::AddAccount { code, typ, placeholder } => {
let _ = l.add_account(&format!("{}", 1000 + u32::from(code % 40)), "f", account_type(typ), None, placeholder);
Op::AddAccount {
code,
typ,
placeholder,
} => {
let _ = l.add_account(
&format!("{}", 1000 + u32::from(code % 40)),
"f",
account_type(typ),
None,
placeholder,
);
}
Op::CreateEntry { day: d } => {
if let Ok(id) = l.try_create_entry(day(d), "f") {
entries.push(id);
}
}
Op::AddLine { entry, account, debit, amount } => {
Op::AddLine {
entry,
account,
debit,
amount,
} => {
if let (Some(e), Some(a)) = (pick(&entries, entry), pick(&accounts, account)) {
let side = if debit { Side::Debit } else { Side::Credit };
let _ = l.add_line(e, a, side, Money::new(amount.get(), Currency::USD));
}
}
Op::Pair { entry, debit, credit, amount } => {
if let (Some(e), Some(a), Some(b)) =
(pick(&entries, entry), pick(&accounts, debit), pick(&accounts, credit))
{
Op::Pair {
entry,
debit,
credit,
amount,
} => {
if let (Some(e), Some(a), Some(b)) = (
pick(&entries, entry),
pick(&accounts, debit),
pick(&accounts, credit),
) {
let m = Money::new(amount.get(), Currency::USD);
let _ = l.add_line(e, a, Side::Debit, m);
let _ = l.add_line(e, b, Side::Credit, m);
}
}
Op::AddLineFx { entry, account, debit, foreign, rate } => {
Op::AddLineFx {
entry,
account,
debit,
foreign,
rate,
} => {
if let (Some(e), Some(a)) = (pick(&entries, entry), pick(&accounts, account)) {
let side = if debit { Side::Debit } else { Side::Credit };
let _ = l.add_line_fx(e, a, side, Currency::EUR, foreign.get(), rate.get());
@@ -111,6 +253,147 @@ fuzz_target!(|ops: Vec<Op>| {
Op::CreatePeriod { start, len } => {
let _ = l.create_period("p", day(start), day(start.saturating_add(len % 60)));
}
Op::CreateInvoice {
day: d,
terms,
amount,
} => {
if let Some(c) = customer
&& let Ok(id) = l.create_invoice_with(
c,
day(d),
day(d) + chrono::Days::new(u64::from(terms)),
doc_line(amount),
Default::default(),
)
{
docs.push(DocRef::Invoice(id));
}
}
Op::CreateBill {
day: d,
terms,
amount,
} => {
if let Some(v) = vendor
&& let Ok(id) = l.create_bill_with(
v,
day(d),
day(d) + chrono::Days::new(u64::from(terms)),
doc_line(amount),
Default::default(),
)
{
docs.push(DocRef::Bill(id));
}
}
Op::Pay {
doc,
day: d,
amount,
from,
reference,
} => {
if !docs.is_empty() {
let cash = if from % 4 == 0 {
pick(&accounts, from)
} else {
bank
};
let details = PaymentDetails {
instrument: None,
reference: reference.then(|| format!("trace {doc}{d}")),
};
if let Some(cash) = cash {
let _ = match docs[doc as usize % docs.len()] {
DocRef::Invoice(id) => {
l.receive_payment_with(id, day(d), amount.get(), cash, &details)
}
DocRef::Bill(id) => {
l.pay_bill_with(id, day(d), amount.get(), cash, &details)
}
};
}
}
}
Op::Settle {
a,
b,
day: d,
cash,
discount,
} => {
if let (false, Some(cash_account)) = (docs.is_empty(), bank) {
let first = docs[a as usize % docs.len()];
let second = docs[b as usize % docs.len()];
let party = match first {
DocRef::Invoice(id) => l.get_invoice(id).map(|i| i.customer_id),
DocRef::Bill(id) => l.get_bill(id).map(|v| v.vendor_id),
};
if let Some(party) = party {
let _ = l.settle(SettleParams {
date: day(d),
description: "two documents, one payment".into(),
cash_account,
party,
applications: vec![
Application {
doc: first,
cash: cash.get(),
discount: discount.get(),
discount_reason: Some("fuzz".into()),
},
Application {
doc: second,
cash: cash.get(),
discount: Decimal::ZERO,
discount_reason: None,
},
],
fee: None,
instrument: None,
reference: None,
});
}
}
}
Op::WriteOff {
doc,
day: d,
amount,
} => {
if !docs.is_empty()
&& let DocRef::Invoice(id) = docs[doc as usize % docs.len()]
{
let _ = l.write_off(id, day(d), amount.get(), "uncollectible");
}
}
Op::VoidDocument { doc, day: d } => {
if !docs.is_empty() {
let _ = l.void_document(docs[doc as usize % docs.len()], day(d), "duplicate");
}
}
Op::SetDraftState {
entry,
awaiting,
note,
} => {
if let Some(e) = pick(&entries, entry) {
let state = if awaiting {
DraftState::AwaitingEvidence {
note: format!("receipt {note}"),
}
} else {
DraftState::Building
};
let _ = l.set_draft_state(e, state);
}
}
Op::DiscardDraft { entry } => {
if let Some(e) = pick(&entries, entry) {
let _ = l.discard_draft(e, "fuzz");
}
}
Op::ClosePeriod { period } => {
let ids: Vec<u64> = l.list_periods().iter().map(|p| p.id).collect();
if let Some(p) = pick(&ids, period) {
@@ -122,13 +405,34 @@ fuzz_target!(|ops: Vec<Op>| {
let tb = ledger_core::reports::trial_balance(&l);
assert_eq!(tb.total_debits, tb.total_credits, "trial balance broken");
for e in l.posted_entries() {
assert!(e.validate(l.chart()).is_ok(), "posted entry {} invalid", e.id());
assert!(
e.validate(l.chart()).is_ok(),
"posted entry {} invalid",
e.id()
);
}
// The control accounts equal their documents, at any day (B-38): too
// large to compute is a refusal, never a difference.
for d in [0u8, 60, 199] {
if let Ok(checks) = l.control_check(day(d)) {
for c in checks {
assert_eq!(
c.difference,
Decimal::ZERO,
"{} differs from its documents",
c.role
);
}
}
}
for id in 1..l.next_entry_id() {
if let Some(e) = l.get_entry(id)
&& e.status() == EntryStatus::Draft
{
assert!(l.closed_period_containing(e.date()).is_none(), "draft in a closed period");
assert!(
l.closed_period_containing(e.date()).is_none(),
"draft in a closed period"
);
}
}
});
+130
View File
@@ -0,0 +1,130 @@
// Fuzz target: a file's bytes come in only by `PUT /b/{book}/u/{upload}`
// (ledger-mcp local.rs INVARIANT 9), through the same in-process router as
// fuzz_http_mcp_request. The body, the declared size and hash, the upload
// id and the token are all arbitrary. Whatever arrives: the door answers,
// and bytes are taken only if they are exactly the declared ones, from the
// token the upload was granted to, once: a 200 for anything else is a bug.
#![no_main]
use arbitrary::Arbitrary;
use ledger_mcp::local::fuzz_door::FuzzDoor;
use libfuzzer_sys::fuzz_target;
use std::cell::RefCell;
#[derive(Arbitrary, Debug)]
enum Declared {
Honest,
/// The size off by this much (never 0).
SizeOff(i8),
/// The hash of other bytes.
HashOf(Vec<u8>),
/// A hash that is not 64 hex digits.
BadHash(String),
}
#[derive(Arbitrary, Debug)]
enum Who {
Owner,
Other,
Nobody,
}
#[derive(Arbitrary, Debug)]
struct Put {
body: Vec<u8>,
declared: Declared,
who: Who,
/// Use an id that was never granted (this text, cleaned).
unknown_id: Option<String>,
/// Send it a second time too.
again: bool,
/// And a body of another length the second time.
second: Vec<u8>,
}
thread_local! {
static DOOR: RefCell<Option<(FuzzDoor, u32)>> = const { RefCell::new(None) };
}
fuzz_target!(|p: Put| {
DOOR.with(|cell| {
let mut cell = cell.borrow_mut();
if cell.as_ref().is_none_or(|(_, n)| *n > 1500) {
*cell = Some((FuzzDoor::new(), 0));
}
let (door, n) = cell.as_mut().unwrap();
*n += 1;
let sha = ledger_store::journal::sha256_hex(&p.body);
let (grant_sha, grant_size) = match &p.declared {
Declared::Honest => (sha.clone(), p.body.len() as u64),
Declared::SizeOff(d) => {
let d = if *d == 0 { 1 } else { i64::from(*d) };
(sha.clone(), (p.body.len() as i64 + d).max(0) as u64)
}
Declared::HashOf(other) => (
ledger_store::journal::sha256_hex(other),
p.body.len() as u64,
),
Declared::BadHash(s) => (s.clone(), p.body.len() as u64),
};
let size_ok = grant_size == p.body.len() as u64 && grant_sha == sha;
let granted = door.grant(&grant_sha, grant_size);
let id = match (&p.unknown_id, &granted) {
(Some(s), _) => {
let s: String = s
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '_')
.take(40)
.collect();
if s.is_empty() { "up_x".to_string() } else { s }
}
(None, Ok(id)) => id.clone(),
(None, Err(_)) => "up_none".to_string(),
};
let real = p.unknown_id.is_none() && granted.is_ok();
let bearer = match p.who {
Who::Owner => Some(door.bearer().to_string()),
Who::Other => Some(door.other_bearer().to_string()),
Who::Nobody => None,
};
let mut headers = Vec::new();
if let Some(b) = &bearer {
headers.push((
b"authorization".to_vec(),
format!("Bearer {b}").into_bytes(),
));
}
let path = format!("/b/acme/u/{id}");
let Some((status, text)) = door.send(b"PUT", &path, &headers, p.body.clone()) else {
return;
};
let may_succeed = real && matches!(p.who, Who::Owner) && size_ok;
if status == 200 {
assert!(
may_succeed,
"bytes taken that should not be: {:?} {:?}",
p.who, p.declared
);
let said = String::from_utf8_lossy(&text);
assert!(
said.contains(&sha),
"the answer doesn't name the bytes' hash: {said}"
);
} else if may_succeed {
// Everything lined up: the only way not to be taken is a
// failure of the door itself.
panic!(
"honest upload by its owner refused: {status} {}",
String::from_utf8_lossy(&text)
);
}
if status == 200 && p.again {
let Some((again, _)) = door.send(b"PUT", &path, &headers, p.second.clone()) else {
return;
};
assert_ne!(again, 200, "an upload taken twice");
}
});
});
+32
View File
@@ -209,6 +209,38 @@ mutants-mcp shard="0/1": sweep-test-tmp
MCP_CASES=16 PROPTEST_MAX_SHRINK_ITERS=0 TMPDIR={{mutants_tmp}} cargo mutants -p ledger-mcp --test-package ledger-mcp --test-package ledger-cli --no-default-features -j 4 --timeout-multiplier 3 --minimum-test-timeout 600 --shard {{shard}} -o mutants-mcp
@just sweep-test-tmp
# Mutation testing of the host (the books home, the lease, the update channel, the bridge): every mutant runs its own tests and the
# suites that drive a real books server (ledger-cli's, D-44), built without
# the viewer, as for the MCP server. A local run, never CI (D-59).
mutants-host shard="0/1": sweep-test-tmp
mkdir -p {{mutants_tmp}}
PROPTEST_MAX_SHRINK_ITERS=0 TMPDIR={{mutants_tmp}} cargo mutants -p ledger-host --test-package ledger-host --test-package ledger-cli --no-default-features -j 4 --timeout-multiplier 3 --minimum-test-timeout 300 --shard {{shard}} -o mutants-host
@just sweep-test-tmp
# Mutation testing of the CPA package and the 1099 worksheet: every mutant runs its own tests and the
# suites that drive a real books server (ledger-cli's, D-44), built without
# the viewer, as for the MCP server. A local run, never CI (D-59).
mutants-package shard="0/1": sweep-test-tmp
mkdir -p {{mutants_tmp}}
PROPTEST_MAX_SHRINK_ITERS=0 TMPDIR={{mutants_tmp}} cargo mutants -p ledger-package --test-package ledger-package --test-package ledger-cli --no-default-features -j 4 --timeout-multiplier 3 --minimum-test-timeout 300 --shard {{shard}} -o mutants-package
@just sweep-test-tmp
# Mutation testing of the TIN vault: every mutant runs its own tests and the
# suites that drive a real books server (ledger-cli's, D-44), built without
# the viewer, as for the MCP server. A local run, never CI (D-59).
mutants-vault shard="0/1": sweep-test-tmp
mkdir -p {{mutants_tmp}}
PROPTEST_MAX_SHRINK_ITERS=0 TMPDIR={{mutants_tmp}} cargo mutants -p ledger-vault --test-package ledger-vault --test-package ledger-cli --no-default-features -j 4 --timeout-multiplier 3 --minimum-test-timeout 120 --shard {{shard}} -o mutants-vault
@just sweep-test-tmp
# Mutation testing of the one executable (its argument handling, the server and bridge modes): every mutant runs its own tests and the
# suites that drive a real books server (ledger-cli's, D-44), built without
# the viewer, as for the MCP server. A local run, never CI (D-59).
mutants-cli shard="0/1": sweep-test-tmp
mkdir -p {{mutants_tmp}}
PROPTEST_MAX_SHRINK_ITERS=0 TMPDIR={{mutants_tmp}} cargo mutants -p ledger-cli --test-package ledger-cli --no-default-features -j 4 --timeout-multiplier 3 --minimum-test-timeout 600 --shard {{shard}} -o mutants-cli
@just sweep-test-tmp
# No mutation runs on the viewer (owner, 2026-10-18: they crushed the
# machine, and for the GUI it's "not as critical"). Its two musts are
# tested directly instead: it never writes the books (tests/read_only.rs)
+2 -2
View File
@@ -1645,7 +1645,7 @@ Each unit follows the 8.0.4 template. "A-n" is an owner answer numbered in 8.3.1
#### P11a: packaging and updates (D-19, D-22, D-24, D-25, D-26)
1. **Status:** in progress: steps 0-4 and the macOS half of step 6 landed 2026-10-29 (the AppImage restart, the daily staged update, the unsigned `.dmg` job); the in-app accept, the viewer's update notice and "Check for updates", and the translocation refusal landed 2026-11-05. Step 5 (signing) is deferred by D-64; left: the release workflow's first run (the owner's click), the Windows installer (P11c's), the trials on a Mac and a Windows machine. P11 was split 2026-10-26 (D-54): P11a is installers and updates; P11b the agent dock and books homes.
1. **Status:** in progress: step 0 landed 2026-10-29 (`5e220cca`) as far as the macOS store and the portable CI job; its keyring stores are removed by U0e (D-63), and the Windows half of step 0 moved to P11c (D-62). Steps 1 to 7 open, macOS and Linux first. P11 was split 2026-10-26 (D-54): P11a is installers and updates; P11b the agent dock and books homes.
2. **Depends on:** none in code. From the owner before the signing steps: the Apple Developer ID certificate and notarization credentials as CI secrets; the Windows signing identity (Azure Artifact Signing if eligible, else an OV certificate); GitHub Releases enabled on the public mirror; the release hardware token; clean VMs or runners for the install tests. Q-4 (keep the two-key release manifest of D-25, or minisign alone) before step 5.
3. **Feature and schema:** none.
4. **Build:**
@@ -1660,7 +1660,7 @@ Each unit follows the 8.0.4 template. "A-n" is an owner answer numbered in 8.3.1
5. **Files:** owns `crates/ledger-host/src/update.rs`, `deploy/packaging/*`, `.github/workflows/release.yml`, `docs/packaging.md`; touches `commands.rs`, `crates/ledger-gui/src/owner.rs`, `mise.toml`.
6. **Tests and proofs:** named above.
7. **Done when:** (1) on a clean Linux VM the AppImage installs, opens, onboards and posts an entry with no terminal and no admin password, and updates itself from a test feed; (2) on Arch, Debian and Fedora the packages install and upgrade with the head unchanged; (3) an update leaves every book's head and goldens unchanged and a bridge session carries on across it; (4) a backup made on one system restores on another; (5) macOS and Windows: the same as (1), once the signing prerequisites exist (record which are done in the As-built block); (6) `just check` green.
8. **As built:** (steps 0 to 3 as far as stated, 2026-10-29; steps 4 to 7 are later waves.) **Step 0, partly.** Built: the macOS login Keychain store (`apple-native-keyring-store` 1.0.2, feature `keychain`) and the Windows Credential Manager store (`windows-native-keyring-store` 1.1.0) behind `tokens.rs::Keeper`, on the same `Items` trait and the same laws as the Secret Service (`os` modules per OS; shared `refused`, `make_in`, `read_item`, `remove_item`); `NOT_YET` now only for other OSes; the ignored `os_keyring_keeps_and_forgets` compiles on all three; the `portable` job in `.github/workflows/check.yml` (`macos-latest` gating, `windows-latest` allowed to fail) builds `ledger-cli --no-default-features`, `cargo build --workspace --all-targets`, `cargo test --workspace` and the ignored keyring test. Checked here: Linux only (fmt, clippy, the tokens tests); no macOS/Windows target is installed, so both stores and the job are proven only by the first CI run. **Not built, and not a `cfg` gap: the Windows host.** Windows data root from the known-folder API with an owner-only DACL, named pipes with a SID check, `flock` and the Unix-only `host`, `start`, `bridge`, `opener` and `local`/`admin` sockets are a port of several crates (inventory in `docs/packaging.md`); macOS needs the CI run to show which Linux-only items (`seccomp` in `refuse.rs`, `renameat2`, `close_range`) need fences. So the step 0 Done-when ("setup ... gui work on each OS") is open for both. **Step 1 (the trial): not run in that wave**: it needs clean macOS, Windows and Linux machines; `mise.toml` now pins `cargo-packager` 0.11.8, `cargo-packager-updater` 0.2.3, `cargo-generate-rpm` 0.21.1 and `docs/packaging.md` holds the empty table and what is known. **Steps 1 (Linux half), 2 and 3 (2026-10-29, branch `P11a`).** *Pins:* the EM removed step 0's early pins; `mise.toml` now pins `cargo-packager` 0.11.8 and `cargo-generate-rpm` 0.21.1, which `just package-linux` calls; **`cargo-packager-updater` is a library, not a tool, so it is not pinned** (the plan's three-tool list was wrong on that); `update.rs` verifies with `minisign-verify` 0.3 (the crate the updater uses) and the tests sign with the `minisign` 0.10 dev-dependency. *Step 1:* `docs/packaging.md` has the Linux rows filled from real builds (47 MB .deb, 36 MB .rpm, 44 MB AppImage, the pacman tarball and PKGBUILD) and eight findings; the `.dmg` and NSIS rows stay "(trial)" (need a Mac and Windows; Windows is P11c). The findings that shaped the build: a binary linked on this machine (glibc 2.44) does not start on Debian 13, so packages are built on `ubuntu-24.04` in CI and in `rust:1.99-trixie` locally (`just release-trixie`); the books refuse to run under Docker's default seccomp filter (tests use `seccomp=unconfined`); an AppImage's launcher appends arguments to the desktop file's `Exec`, so the desktop files say plain `wanderbooks` and a bare launch with no terminal is the viewer (`main.rs::is_bare_launch`); the AppImage needs a square icon (**a placeholder PNG is committed; the owner should replace it**). *Step 2:* `crates/ledger-host/src/update.rs` (`update`, `rollback`) and the two CLI modes: a manifest (version; per platform `url`, `sha256`, `signature` = base64 of the minisign `.sig`, the shape cargo-packager signs in), accepted only if higher, sha256 equal AND signature valid against `WANDERBOOKS_RELEASE_PUBKEY` (compiled in; **no build carries one until step 5**, so today a build updates only from a `file://` feed with `--trusted-key`); stage under `<root>/updates/<version>/`, keep the running version under `updates/previous/` with the journal format it knew, swap by renaming a complete copy made beside the target, `--help` health check with automatic put-back; `rollback` refuses when a book declares a format or feature the kept version lacks; a package marks itself with `share/wanderbooks/install-method` and then both refuse. The restart is the host's existing one (nothing new in `host.rs`). Tests: `crates/ledger-cli/tests/update.rs` (`update_is_atomic`, `failed_update_rolls_back`, `unsigned_update_refused`, `bridge_survives_update`, `update_waits_for_a_running_call`, `package_managed_install_refuses_update`, `rollback_refuses_after_a_book_consented_to_a_newer_format`, `an_update_replaces_the_program_keeps_the_old_one_and_rollback_brings_it_back`) all pass (8 in 12 s); five unit tests in `update.rs`. *Step 3:* `deploy/packaging/` (`packager.toml`, the rpm table in `crates/ledger-cli/Cargo.toml`, `aur/PKGBUILD`, `build-linux.sh`, `repo-indexes.sh`, `local-release.sh`, `test-linux-packages.sh`), the `just` recipes `package-linux`, `local-release`, `release-trixie`, `test-linux-packages`, and `.github/workflows/release.yml` (Linux packages on a `v*` tag, attached to the release, apt and dnf indexes pushed to `gh-pages`; untested, GitHub only). `linux_packages_install_and_upgrade` is `test-linux-packages.sh`: install 0.1.0 from an apt, a dnf repository and `makepkg` over the AUR PKGBUILLine truncated
8. **As built:** (steps 0 to 3 as far as stated, 2026-10-29; steps 4 to 7 are later waves.) **Step 0, partly.** Built: the macOS login Keychain store (`apple-native-keyring-store` 1.0.2, feature `keychain`) and the Windows Credential Manager store (`windows-native-keyring-store` 1.1.0) behind `tokens.rs::Keeper`, on the same `Items` trait and the same laws as the Secret Service (`os` modules per OS; shared `refused`, `make_in`, `read_item`, `remove_item`); `NOT_YET` now only for other OSes; the ignored `os_keyring_keeps_and_forgets` compiles on all three; the `portable` job in `.github/workflows/check.yml` (`macos-latest` gating, `windows-latest` allowed to fail) builds `ledger-cli --no-default-features`, `cargo build --workspace --all-targets`, `cargo test --workspace` and the ignored keyring test. Checked here: Linux only (fmt, clippy, the tokens tests); no macOS/Windows target is installed, so both stores and the job are proven only by the first CI run. **Not built, and not a `cfg` gap: the Windows host.** Windows data root from the known-folder API with an owner-only DACL, named pipes with a SID check, `flock` and the Unix-only `host`, `start`, `bridge`, `opener` and `local`/`admin` sockets are a port of several crates (inventory in `docs/packaging.md`); macOS needs the CI run to show which Linux-only items (`seccomp` in `refuse.rs`, `renameat2`, `close_range`) need fences. So the step 0 Done-when ("setup ... gui work on each OS") is open for both. **Step 1 (the trial): not run in that wave**: it needs clean macOS, Windows and Linux machines; `mise.toml` now pins `cargo-packager` 0.11.8, `cargo-packager-updater` 0.2.3, `cargo-generate-rpm` 0.21.1 and `docs/packaging.md` holds the empty table and what is known. **Steps 1 (Linux half), 2 and 3 (2026-10-29, branch `P11a`).** *Pins:* the EM removed step 0's early pins; `mise.toml` now pins `cargo-packager` 0.11.8 and `cargo-generate-rpm` 0.21.1, which `just package-linux` calls; **`cargo-packager-updater` is a library, not a tool, so it is not pinned** (the plan's three-tool list was wrong on that); `update.rs` verifies with `minisign-verify` 0.3 (the crate the updater uses) and the tests sign with the `minisign` 0.10 dev-dependency. *Step 1:* `docs/packaging.md` has the Linux rows filled from real builds (47 MB .deb, 36 MB .rpm, 44 MB AppImage, the pacman tarball and PKGBUILD) and eight findings; the `.dmg` and NSIS rows stay "(trial)" (need a Mac and Windows; Windows is P11c). The findings that shaped the build: a binary linked on this machine (glibc 2.44) does not start on Debian 13, so packages are built on `ubuntu-24.04` in CI and in `rust:1.99-trixie` locally (`just release-trixie`); the books refuse to run under Docker's default seccomp filter (tests use `seccomp=unconfined`); an AppImage's launcher appends arguments to the desktop file's `Exec`, so the desktop files say plain `wanderbooks` and a bare launch with no terminal is the viewer (`main.rs::is_bare_launch`); the AppImage needs a square icon (**a placeholder PNG is committed; the owner should replace it**). *Step 2:* `crates/ledger-host/src/update.rs` (`update`, `rollback`) and the two CLI modes: a manifest (version; per platform `url`, `sha256`, `signature` = base64 of the minisign `.sig`, the shape cargo-packager signs in), accepted only if higher, sha256 equal AND signature valid against `WANDERBOOKS_RELEASE_PUBKEY` (compiled in; **no build carries one until step 5**, so today a build updates only from a `file://` feed with `--trusted-key`); stage under `<root>/updates/<version>/`, keep the running version under `updates/previous/` with the journal format it knew, swap by renaming a complete copy made beside the target, `--help` health check with automatic put-back; `rollback` refuses when a book declares a format or feature the kept version lacks; a package marks itself with `share/wanderbooks/install-method` and then both refuse. The restart is the host's existing one (nothing new in `host.rs`). Tests: `crates/ledger-cli/tests/update.rs` (`update_is_atomic`, `failed_update_rolls_back`, `unsigned_update_refused`, `bridge_survives_update`, `update_waits_for_a_running_call`, `package_managed_install_refuses_update`, `rollback_refuses_after_a_book_consented_to_a_newer_format`, `an_update_replaces_the_program_keeps_the_old_one_and_rollback_brings_it_back`) all pass (8 in 12 s); five unit tests in `update.rs`. *Step 3:* `deploy/packaging/` (`packager.toml`, the rpm table in `crates/ledger-cli/Cargo.toml`, `aur/PKGBUILD`, `build-linux.sh`, `repo-indexes.sh`, `local-release.sh`, `test-linux-packages.sh`), the `just` recipes `package-linux`, `local-release`, `release-trixie`, `test-linux-packages`, and `.github/workflows/release.yml` (Linux packages on a `v*` tag, attached to the release, apt and dnf indexes pushed to `gh-pages`; untested, GitHub only). `linux_packages_install_and_upgrade` is `test-linux-packages.sh`: install 0.1.0 from an apt, a dnf repository and `makepkg` over the AUR PKGBUILLine truncated
#### P11b: the agent dock and books homes (D-27)