Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48062b89f0 | ||
|
|
7002e89efd | ||
|
|
5e474cd1ac | ||
|
|
4b0196e416 | ||
|
|
cecff55db6 |
No files matched your search
@@ -33,7 +33,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: jdx/mise-action@v2
|
- uses: jdx/mise-action@v2
|
||||||
|
|||||||
@@ -172,21 +172,6 @@ impl ledger_gui::owner::Owner for ViewerOwner {
|
|||||||
fn upgrade_format(&self, book: &str, passphrase: &str) -> Result<Vec<String>, String> {
|
fn upgrade_format(&self, book: &str, passphrase: &str) -> Result<Vec<String>, String> {
|
||||||
ledger_host::commands::upgrade_format(&self.0, book, passphrase)
|
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
|
/// `wanderbooks gui`: the viewer, in this process. For books of the data
|
||||||
|
|||||||
@@ -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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1040,137 +1040,3 @@ fn a_staged_update_that_was_tampered_with_is_dropped_not_applied() {
|
|||||||
"the tampered copy was kept"
|
"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);
|
|
||||||
}
|
|
||||||
@@ -132,16 +132,6 @@ pub struct RestorePlan {
|
|||||||
pub lines: Vec<String>,
|
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
|
/// 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
|
/// over the books server and the agents' own commands). Each blocks: the
|
||||||
/// menu calls it off the UI thread.
|
/// 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).
|
/// Hold the books `book` open while the viewer runs (the viewer's lease).
|
||||||
fn hold(&self, _book: &str) {}
|
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
|
/// 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).
|
/// dock starts it on a terminal of its own (D-27).
|
||||||
fn agent_start(&self, _agent: Agent, _book: &str) -> Result<AgentStart, String> {
|
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
|
/// The books whose format upgrade was asked for since the viewer
|
||||||
/// opened them (INVARIANT 5: once each open).
|
/// opened them (INVARIANT 5: once each open).
|
||||||
asked_upgrade: Option<String>,
|
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 {
|
impl OwnerMenu {
|
||||||
/// The id of the books shown, if they are books of a data root: found
|
/// The id of the books shown, if they are books of a data root: found
|
||||||
/// once for each load.
|
/// once for each load.
|
||||||
@@ -335,73 +304,15 @@ impl OwnerMenu {
|
|||||||
self.book.as_ref().and_then(|(_, id)| id.clone())
|
self.book.as_ref().and_then(|(_, id)| id.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The update waiting, if any (read now and then, not every frame).
|
/// The standing notice of a format upgrade the books wait for, above
|
||||||
fn update_waiting(
|
/// the page, and the go-ahead asked for once each open (INVARIANT 5).
|
||||||
&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).
|
|
||||||
pub fn notice(&mut self, ui: &mut Ui, books: &Arc<Books>) {
|
pub fn notice(&mut self, ui: &mut Ui, books: &Arc<Books>) {
|
||||||
let Some(owner) = owner() else {
|
if owner().is_none() {
|
||||||
return;
|
return;
|
||||||
};
|
}
|
||||||
let Some(book) = self.book_of(books) else {
|
let Some(book) = self.book_of(books) else {
|
||||||
return;
|
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 {
|
let Some(upgrade) = books.format_upgrade.clone() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -467,7 +378,6 @@ impl OwnerMenu {
|
|||||||
{
|
{
|
||||||
self.done = Some((what.clone(), done));
|
self.done = Some((what.clone(), done));
|
||||||
self.job = None;
|
self.job = None;
|
||||||
self.update = None;
|
|
||||||
}
|
}
|
||||||
let text = if compact {
|
let text = if compact {
|
||||||
RichText::new(icon::BOOKS).size(16.0).color(p.muted)
|
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));
|
button = button.min_size(vec2(32.0, ui.spacing().interact_size.y + 4.0));
|
||||||
}
|
}
|
||||||
let busy = self.job.is_some();
|
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 mut start: Option<(String, Box<dyn FnOnce() -> Outcome + Send>)> = None;
|
||||||
let response = ui
|
let response = ui
|
||||||
.add_enabled_ui(!busy, |ui| {
|
.add_enabled_ui(!busy, |ui| {
|
||||||
@@ -540,24 +449,6 @@ impl OwnerMenu {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ui.separator();
|
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
|
if ui
|
||||||
.button(format!("{} Close the books", icon::POWER))
|
.button(format!("{} Close the books", icon::POWER))
|
||||||
.on_hover_text("Close the books now: agents' sessions and every other holder let go")
|
.on_hover_text("Close the books now: agents' sessions and every other holder let go")
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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.
|
/// The longest an agent's own command may take to add the books.
|
||||||
const CONNECT_WAIT: Duration = Duration::from_secs(60);
|
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)"
|
"{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();
|
let mut lines = Vec::new();
|
||||||
// The token: kept in its file, or made now.
|
// The token: kept in its file, or made now.
|
||||||
let found =
|
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.
|
// The agent's own list: the books' entry made anew.
|
||||||
|
let exe = crate::start::this_executable()?;
|
||||||
let name = format!("wanderbooks-{book}");
|
let name = format!("wanderbooks-{book}");
|
||||||
let mut serve: Vec<std::ffi::OsString> = vec![
|
let mut serve: Vec<std::ffi::OsString> = vec![
|
||||||
exe.into_os_string(),
|
exe.into_os_string(),
|
||||||
|
|||||||
@@ -648,9 +648,6 @@ impl Host {
|
|||||||
Event::Intent(Intent::ConsentFormatUpgrade { book, passphrase }, answer) => {
|
Event::Intent(Intent::ConsentFormatUpgrade { book, passphrase }, answer) => {
|
||||||
self.consent(book, passphrase.unwrap_or_default(), answer);
|
self.consent(book, passphrase.unwrap_or_default(), answer);
|
||||||
}
|
}
|
||||||
Event::Intent(Intent::ApplyUpdate, answer) => {
|
|
||||||
self.apply_update(answer);
|
|
||||||
}
|
|
||||||
Event::Intent(Intent::Close, answer) => {
|
Event::Intent(Intent::Close, answer) => {
|
||||||
let _ = answer.send(HostMsg::Done {
|
let _ = answer.send(HostMsg::Done {
|
||||||
detail: Some("the books are closing".into()),
|
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());
|
let (root, every, looking) = (self.root.clone(), self.knobs.update_check, looking.clone());
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
match crate::update::stage_if_due(&root, crate::update::program_version(), every) {
|
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) => {}
|
Ok(None) => {}
|
||||||
Err(why) => eprintln!("wanderbooks host: the look for a newer version failed: {why}"),
|
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).
|
/// Close the server's input: it closes the books (INVARIANT 3).
|
||||||
fn close_server(&mut self) {
|
fn close_server(&mut self) {
|
||||||
self.server_stdin = None;
|
self.server_stdin = None;
|
||||||
|
|||||||
@@ -16,8 +16,7 @@
|
|||||||
//! 1. A connection's first line is one Ask; a Hold keeps the
|
//! 1. A connection's first line is one Ask; a Hold keeps the
|
||||||
//! connection (the lease lasts until it closes), anything else is
|
//! connection (the lease lasts until it closes), anything else is
|
||||||
//! answered once and the connection closed.
|
//! answered once and the connection closed.
|
||||||
//! 2. Exactly three intents exist: close, consent_format_upgrade and
|
//! 2. Exactly two intents exist: close and consent_format_upgrade; any
|
||||||
//! apply_update; any
|
|
||||||
//! other request is refused.
|
//! other request is refused.
|
||||||
//! 3. consent_format_upgrade carries the owner passphrase; without one
|
//! 3. consent_format_upgrade carries the owner passphrase; without one
|
||||||
//! it is refused before anything is done. With one, the host hands it
|
//! it is refused before anything is done. With one, the host hands it
|
||||||
@@ -107,7 +106,7 @@ pub enum Ask {
|
|||||||
Intent(Intent),
|
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)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case", deny_unknown_fields)]
|
#[serde(rename_all = "snake_case", deny_unknown_fields)]
|
||||||
pub enum Intent {
|
pub enum Intent {
|
||||||
@@ -121,10 +120,6 @@ pub enum Intent {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
passphrase: Option<ledger_mcp::admin::Passphrase>,
|
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 {
|
impl Intent {
|
||||||
@@ -263,10 +258,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn exactly_three_intents_are_understood() {
|
fn exactly_two_intents_are_understood() {
|
||||||
for intent in [
|
for intent in [
|
||||||
json!("close"),
|
json!("close"),
|
||||||
json!("apply_update"),
|
|
||||||
json!({"consent_format_upgrade": {"book": "acme", "passphrase": "x"}}),
|
json!({"consent_format_upgrade": {"book": "acme", "passphrase": "x"}}),
|
||||||
] {
|
] {
|
||||||
let h = parse(json!({"proto": 1, "intent": intent})).unwrap();
|
let h = parse(json!({"proto": 1, "intent": intent})).unwrap();
|
||||||
|
|||||||
@@ -83,21 +83,6 @@ pub fn this_executable() -> Result<PathBuf, String> {
|
|||||||
Ok(exe)
|
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
|
/// The AppImage this process runs from: `APPIMAGE` when it is an
|
||||||
/// absolute path to a file.
|
/// absolute path to a file.
|
||||||
pub fn own_appimage(value: Option<&std::ffi::OsStr>) -> Option<std::ffi::OsString> {
|
pub fn own_appimage(value: Option<&std::ffi::OsStr>) -> Option<std::ffi::OsString> {
|
||||||
@@ -648,28 +633,6 @@ mod tests {
|
|||||||
let exe = this_executable().unwrap();
|
let exe = this_executable().unwrap();
|
||||||
assert!(exe.is_file(), "{exe:?}");
|
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))]
|
#[cfg(all(test, windows))]
|
||||||
|
|||||||
@@ -157,8 +157,7 @@ pub fn install_kind(exe: &Path, appimage: Option<&Path>) -> Install {
|
|||||||
Install::SelfUpdating(exe.to_path_buf())
|
Install::SelfUpdating(exe.to_path_buf())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How the running program was installed.
|
fn this_install() -> Result<Install, String> {
|
||||||
pub fn this_install() -> Result<Install, String> {
|
|
||||||
let exe = crate::start::this_executable()?;
|
let exe = crate::start::this_executable()?;
|
||||||
let image = std::env::var_os("APPIMAGE").map(PathBuf::from);
|
let image = std::env::var_os("APPIMAGE").map(PathBuf::from);
|
||||||
Ok(install_kind(&exe, image.as_deref()))
|
Ok(install_kind(&exe, image.as_deref()))
|
||||||
@@ -495,10 +494,7 @@ pub fn apply_staged(
|
|||||||
let Install::SelfUpdating(target) = this_install()? else {
|
let Install::SelfUpdating(target) = this_install()? else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
// The host's own look at the feed can still hold the lock for a moment
|
let Ok(_lock) = take_lock(&updates) else {
|
||||||
// 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 {
|
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
let meta: serde_json::Value = std::fs::read(dir.join(META))
|
let meta: serde_json::Value = std::fs::read(dir.join(META))
|
||||||
|
|||||||
@@ -39,12 +39,18 @@ toml = "1"
|
|||||||
# The vault's TINs, decoded for the filing scan (tin_files.rs), are wiped
|
# The vault's TINs, decoded for the filing scan (tin_files.rs), are wiped
|
||||||
# from memory when dropped, as the vault's own are.
|
# from memory when dropped, as the vault's own are.
|
||||||
zeroize = "1"
|
zeroize = "1"
|
||||||
|
tempfile = { version = "3", optional = true }
|
||||||
|
|
||||||
# The socket's peer credentials (SO_PEERCRED, getpeereid) for local.rs's
|
# The socket's peer credentials (SO_PEERCRED, getpeereid) for local.rs's
|
||||||
# same-user check: std has no call for them.
|
# same-user check: std has no call for them.
|
||||||
[target.'cfg(unix)'.dependencies]
|
[target.'cfg(unix)'.dependencies]
|
||||||
libc = "0.2"
|
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]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
/// Anything but `/b/{book}/mcp`.
|
||||||
async fn not_found() -> (axum::http::StatusCode, &'static str) {
|
async fn not_found() -> (axum::http::StatusCode, &'static str) {
|
||||||
(axum::http::StatusCode::NOT_FOUND, "not found")
|
(axum::http::StatusCode::NOT_FOUND, "not found")
|
||||||
@@ -2080,13 +2096,7 @@ async fn serve(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let app = axum::Router::new()
|
let app = router(books.clone());
|
||||||
.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());
|
|
||||||
// The HTTP side keeps answering (refusing, once closing) until the
|
// The HTTP side keeps answering (refusing, once closing) until the
|
||||||
// close is done: a client is never cut off mid-request by it.
|
// close is done: a client is never cut off mid-request by it.
|
||||||
let shut = CancellationToken::new();
|
let shut = CancellationToken::new();
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -242,17 +242,6 @@ pub struct FormatUpgradeOut {
|
|||||||
pub how: String,
|
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.
|
/// Something to do next.
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
pub struct NextStepOut {
|
pub struct NextStepOut {
|
||||||
@@ -402,10 +391,6 @@ pub struct StartOut {
|
|||||||
/// the owner gives the go-ahead. No tool can: tell the human.
|
/// the owner gives the go-ahead. No tool can: tell the human.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub format_upgrade: Option<FormatUpgradeOut>,
|
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.
|
/// Who is asking.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub you: Option<YouOut>,
|
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.",
|
"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" {
|
if onboarding || focus == "onboarding" {
|
||||||
next_steps.push(NextStepOut {
|
next_steps.push(NextStepOut {
|
||||||
tool: None,
|
tool: None,
|
||||||
@@ -1274,7 +1240,6 @@ impl BooksServer {
|
|||||||
title: handbook::title(md).to_string(),
|
title: handbook::title(md).to_string(),
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
update_waiting,
|
|
||||||
format_upgrade: upgrade.map(|u| FormatUpgradeOut {
|
format_upgrade: upgrade.map(|u| FormatUpgradeOut {
|
||||||
guard: ledger_store::error::FORMAT_UPGRADE_PENDING.to_string(),
|
guard: ledger_store::error::FORMAT_UPGRADE_PENDING.to_string(),
|
||||||
format: u.from.format,
|
format: u.from.format,
|
||||||
|
|||||||
@@ -661,12 +661,6 @@ impl Root {
|
|||||||
.map_err(|e| self.hide(e))
|
.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.
|
/// The folder a book lives in.
|
||||||
pub fn book_dir(&self, id: &BookId) -> PathBuf {
|
pub fn book_dir(&self, id: &BookId) -> PathBuf {
|
||||||
self.path.join(BOOKS_DIR).join(id.as_str())
|
self.path.join(BOOKS_DIR).join(id.as_str())
|
||||||
@@ -2400,42 +2394,3 @@ mod unit {
|
|||||||
assert_eq!(keys, book.join("keys"));
|
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
@@ -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
|
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
|
`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
|
local release is named in `<root>/updates/feed` (a `file://` address, then
|
||||||
the key file), the same rule as `--trusted-key`;
|
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.
|
|
||||||
|
|
||||||
`deploy/packaging/` holds the Linux packaging: `packager.toml`
|
`deploy/packaging/` holds the Linux packaging: `packager.toml`
|
||||||
(cargo-packager: `.deb`, AppImage, the pacman tarball with its PKGBUILD),
|
(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
|
`.github/workflows/release.yml` builds them on a version tag. The macOS
|
||||||
bundle is `packager-macos.toml`, `Info.plist` and `build-macos.sh` (an
|
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).
|
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)
|
## The result envelope and the guards (`ledger-mcp`, P5)
|
||||||
|
|
||||||
|
|||||||
@@ -124,8 +124,7 @@ settings-and-conventions section says how to use them.
|
|||||||
codes), backup: { last_at, through_seq, stale }, prompts: [{ name, args
|
codes), backup: { last_at, through_seq, stale }, prompts: [{ name, args
|
||||||
}], needs_attention: { items: [{ id, severity, kind, title, tool?, args?
|
}], needs_attention: { items: [{ id, severity, kind, title, tool?, args?
|
||||||
}], counts: { kind: n } }, truncated?, handbook_sections,
|
}], counts: { kind: n } }, truncated?, handbook_sections,
|
||||||
format_upgrade?: { guard, format, adds, how }, update_waiting?: { version,
|
format_upgrade?: { guard, format, adds, how }, next_steps: [{ tool |
|
||||||
staged_at, how }, next_steps: [{ tool |
|
|
||||||
prompt, args?, why, required }], isolation }`. The connect text names it
|
prompt, args?, why, required }], isolation }`. The connect text names it
|
||||||
as the first call. `book.url` is the book's path on this server
|
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
|
(`/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
|
(below); its next step comes first, before anything else, at every
|
||||||
start until it is done: `books.handbook {section: "format-upgrade"}`,
|
start until it is done: `books.handbook {section: "format-upgrade"}`,
|
||||||
"Tell the human first: …".
|
"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
|
`isolation` is `same_user (advisory)` locally (D-32): the books are
|
||||||
hidden from agents in the owner's own account, not locked against them.
|
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
|
On the owner's own machine, where the server runs as a separate account
|
||||||
|
|||||||
+5
-28
@@ -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.
|
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
|
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
|
the program inside a signed `.app` breaks its signature (it will: step 5
|
||||||
must decide, probably by updating a whole `.app`). **Translocation
|
must decide, probably by updating a whole `.app`); the translocated-app
|
||||||
(P11a step 6):** a `.dmg` downloaded from the internet keeps its
|
check (`translocated_app_refuses_to_connect`) is not built.
|
||||||
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.
|
|
||||||
|
|
||||||
## Updates staged by the host (step 4)
|
## 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
|
`update --check` says "already downloaded". A local release is named by
|
||||||
`<root>/updates/feed` (a `file://` address, then the key file). Without a
|
`<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
|
compiled-in release key and without that file, the look does nothing but
|
||||||
log why.
|
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
|
||||||
The owner accepts a staged update **in the app** (P11a step 4). The
|
viewer (see the unit's As-built block).
|
||||||
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.
|
|
||||||
|
|
||||||
## What `update` and `rollback` need from a release
|
## What `update` and `rollback` need from a release
|
||||||
|
|
||||||
|
|||||||
+542
-45
@@ -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`,
|
`settings.rs`, `ledger-mcp` `backup.rs`, `tools/backup.rs`, `local.rs`,
|
||||||
`admin.rs`, `ledger-host` `commands.rs`, `ledger-cli`). The survivor lists
|
`admin.rs`, `ledger-host` `commands.rs`, `ledger-cli`). The survivor lists
|
||||||
are kept with the run's output, outside the repository.
|
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)
|
## 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
|
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
|
`ledger-import` (R-6); the store and MCP mutants above are for the whole
|
||||||
pass.
|
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)
|
## 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`
|
`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
|
`parties.rs` `tin_separator`'s arms and `tin_invisible`, and the gap that
|
||||||
doesn't count them.
|
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)
|
## P3: cash flow (defect 1, 2026-10-20)
|
||||||
|
|
||||||
@@ -1080,6 +1083,7 @@ the end.
|
|||||||
`export_cash_flow` and `settings_problems`; zero unexplained survivors
|
`export_cash_flow` and `settings_problems`; zero unexplained survivors
|
||||||
(plan P3 accept). Likely equivalent, to adjudicate: `min` against `max`
|
(plan P3 accept). Likely equivalent, to adjudicate: `min` against `max`
|
||||||
in `take` where both bounds coincide, and the kind order's tie-breaks.
|
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)
|
## 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`,
|
`created_uncategorized`, the digest's `uncategorized_accounts`,
|
||||||
`handbook::period_rule`. The viewer is not mutated (owner, 2026-10-18):
|
`handbook::period_rule`. The viewer is not mutated (owner, 2026-10-18):
|
||||||
`view_model.rs` and `show.rs` cover it.
|
`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)
|
## 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_what_for` and `share_text`. Expected equivalent:
|
||||||
`Party::tin_last4`'s `or_else` order swapped (TIN facts and W-9 facts
|
`Party::tin_last4`'s `or_else` order swapped (TIN facts and W-9 facts
|
||||||
never disagree, parties INVARIANT 2a, enforced on replay).
|
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)
|
## 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
|
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
|
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).
|
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)
|
## 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
|
unknown-code check, the accounts passed) and `update_party`'s feature
|
||||||
declaration; the bookspec loader's party, payee, decision and payment
|
declaration; the bookspec loader's party, payee, decision and payment
|
||||||
method setup.
|
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)
|
## 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`,
|
(`consent_now`, `upgrade_format`, `offer_upgrades`, `offer_upgrade`,
|
||||||
`upgrade`, `book_line`, `upgraded_lines`, `upgrade_explained`,
|
`upgrade`, `book_line`, `upgraded_lines`, `upgrade_explained`,
|
||||||
`ASKED_UPGRADES`). None run.
|
`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)
|
## 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
|
`entry_property` (the mismatch branch, the unknown sum), the open-item
|
||||||
conditions (`in_service`, the disposal window), and the reconciliation's
|
conditions (`in_service`, the disposal window), and the reconciliation's
|
||||||
signs.
|
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)
|
## 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).
|
`cash_accounts` check; `documented_tools` (its own sample checks it).
|
||||||
The earlier P3 sections' lists stand (one name corrected:
|
The earlier P3 sections' lists stand (one name corrected:
|
||||||
`Ledger::depreciation_runs`, which was `depreciation_runs_ever`).
|
`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)
|
## 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
|
`set_project`'s unchanged short-cut; `subject_feature`; `check_link_to`'s
|
||||||
feature check; the decision `projects` omit-when-empty;
|
feature check; the decision `projects` omit-when-empty;
|
||||||
`late_tag_note`'s `is_over`; `remember_footer`'s threshold.
|
`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)
|
## P3: the project snapshot (2026-10-22)
|
||||||
|
|
||||||
@@ -2068,6 +2080,7 @@ fail.
|
|||||||
**For P12 (mutation targets):** `contribution_margin_pct`'s `<=` and
|
**For P12 (mutation targets):** `contribution_margin_pct`'s `<=` and
|
||||||
rounding strategy; `project_figures`' opening net and running sum;
|
rounding strategy; `project_figures`' opening net and running sum;
|
||||||
`profit_and_loss_counted`'s counts; `Standing::of`'s order.
|
`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)
|
## 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
|
`document_notes`' dedup and its `invoice` flag; `on_spec_invoice_note`'s
|
||||||
kind test; `document_projects_rule`'s branch; the viewer's
|
kind test; `document_projects_rule`'s branch; the viewer's
|
||||||
`document_of` (invoice before bill).
|
`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)
|
## 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`;
|
`doc_status`'s Overpaid and Unattached arms; the aging's `<= 0`;
|
||||||
`ensure_payment_features`' comparison; `check_document_reversal`'s date
|
`ensure_payment_features`' comparison; `check_document_reversal`'s date
|
||||||
check; `control_check`'s chain netting.
|
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)
|
## 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
|
invoice/bill kind); `control_problems` (one message per unattached line
|
||||||
and per document, both loops); the `digest` that includes it and the
|
and per document, both loops); the `digest` that includes it and the
|
||||||
`verify` check that refuses a nonzero difference.
|
`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)
|
## 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
|
shown only under All (`Ok(None)`), the list's `(due, date, name)` sort
|
||||||
and the timeline's movement×reversal labels with their `(reversed later)`
|
and the timeline's movement×reversal labels with their `(reversed later)`
|
||||||
condition (`!t.reversal && !stands_on(t.entry, b.today)`).
|
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)
|
## 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
|
`Draft` (only S4's preview reaches it; the renderer's own test renders
|
||||||
it but asserts only that it succeeds).
|
it but asserts only that it succeeds).
|
||||||
## P7: the TIN scanner's separators (2026-10-26)
|
## 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
|
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
|
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
|
either readable or unmasked, which the truth table and the property
|
||||||
catch.
|
catch.
|
||||||
## P13: bookspec, the checker crate and the runner (2026-10-26)
|
## 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's example schema moved out of the loader into
|
||||||
`bookspec/checker`, its own Cargo workspace inside the repository (P13
|
`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)
|
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
|
and every `answer_*` figure against the loader's same check (the two must
|
||||||
agree on all 56 examples).
|
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)
|
## 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
|
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
|
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).
|
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)
|
## 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::
|
intact-blob set (a damaged blob reported per attachment) and `Store::
|
||||||
disk_check`'s snapshot load and `Unreadable` arm; the viewer wrapper's
|
disk_check`'s snapshot load and `Unreadable` arm; the viewer wrapper's
|
||||||
`Target` arms.
|
`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)
|
## 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`
|
`isolation_label_is_enforced_under_the_unit`); the host's `server_flags`
|
||||||
extension and `parse_args`' passthrough of `--isolation` and its optional
|
extension and `parse_args`' passthrough of `--isolation` and its optional
|
||||||
`--http` value.
|
`--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)
|
## 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
|
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`
|
handbook's `documents_v2_rule` both arms and the server's `documents_v2`
|
||||||
feature test.
|
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)
|
## 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
|
`checked_add_days` `unsent_days` comparison (`>`), `overdue_days > 0`, the
|
||||||
`sends.max()` last send, and the `reminder_days` comparison (`>`), plus
|
`sends.max()` last send, and the `reminder_days` comparison (`>`), plus
|
||||||
each item's target (`Target::Invoice`) and `books.mark_sent` step args.
|
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)
|
## 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
|
the running-total grouping; `aging_documents`'s outstanding filter and
|
||||||
bucket; `aging_csv`'s tie-out columns and `aging_statement`'s totals; the
|
bucket; `aging_csv`'s tie-out columns and `aging_statement`'s totals; the
|
||||||
viewer's `discount_by` and `days_overdue` flags.
|
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
|
## 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
|
feature gate; the MCP handler's amount default, the exactly-one evidence
|
||||||
check, and the preview's clone; attention's year comparison and its
|
check, and the preview's clone; attention's year comparison and its
|
||||||
`stands_on` filter; the bookspec loader's `may_refuse` arm.
|
`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)
|
## 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
|
`ledger-package::attention`: the extraction `seq` comparison and the
|
||||||
`Failed` filter, the owed-account name test, the credit side and the
|
`Failed` filter, the owed-account name test, the credit side and the
|
||||||
`settles_entry` skip.
|
`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)
|
## 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
|
**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
|
are not reachable by mutation testing (they need the real OS store); the
|
||||||
`Items` logic they share is, through the file store.
|
`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)
|
## 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
|
`Keeper::delete`'s `NotFound` arm, and `Account::key`'s three parts
|
||||||
(`an_account_names_the_root_by_hash_and_the_book_and_token`);
|
(`an_account_names_the_root_by_hash_and_the_book_and_token`);
|
||||||
`bridge.rs::token_file_refusal`'s `holds_token` guard.
|
`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)
|
## 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;
|
`zero_net_accounts` branch and the `never_reconciled` split;
|
||||||
`report_status`'s three open conditions; the open-drafts refusal's
|
`report_status`'s three open conditions; the open-drafts refusal's
|
||||||
`<= as_of` and its decision; the attention `awaiting_evidence` branch.
|
`<= 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)
|
## 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
|
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:
|
list. None of the Windows arms is reachable by `cargo mutants` on Linux:
|
||||||
they run on the Windows job only.
|
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)
|
## 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`
|
add), `swap_in`'s temporary-then-rename (`update_is_atomic`), `healthy`
|
||||||
(`failed_update_rolls_back`), `install_kind` (the marker test), and
|
(`failed_update_rolls_back`), `install_kind` (the marker test), and
|
||||||
`parse_version`'s rejections.
|
`parse_version`'s rejections.
|
||||||
|
*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)*
|
||||||
## 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).
|
|
||||||
|
|
||||||
## P5: the agent surface (2026-10-29)
|
## 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
|
in `a_source_only_revision_is_journaled`); `books.start`'s fit order and
|
||||||
`reconciliation` split (`reconciled_through.is_some()`).
|
`reconciliation` split (`reconciled_through.is_some()`).
|
||||||
## P8: audit and attestations (2026-10-29)
|
## 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
|
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
|
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
|
the `refuse`/`warn` split, the reason's trim); `audit_items` in Needs
|
||||||
attention (the declared test, the attested set, the last-ended fiscal
|
attention (the declared test, the attested set, the last-ended fiscal
|
||||||
year, the three year conditions).
|
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
|
## 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
|
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
|
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.
|
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)
|
## 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);
|
open/closed ids (a remembered size must not leak between them);
|
||||||
`Welcome::steps`' choice of `now` (each arm), the passphrase length and
|
`Welcome::steps`' choice of `now` (each arm), the passphrase length and
|
||||||
equality gates, the `agent_later` flag.
|
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.
|
||||||
Generated
+934
-1
File diff suppressed because it is too large.
Load diff
@@ -24,6 +24,10 @@ path = "../crates/ledger-store"
|
|||||||
[dependencies.ledger-import]
|
[dependencies.ledger-import]
|
||||||
path = "../crates/ledger-import"
|
path = "../crates/ledger-import"
|
||||||
|
|
||||||
|
[dependencies.ledger-mcp]
|
||||||
|
path = "../crates/ledger-mcp"
|
||||||
|
features = ["fuzzing"]
|
||||||
|
|
||||||
[dependencies.ledger-config]
|
[dependencies.ledger-config]
|
||||||
path = "../crates/ledger-config"
|
path = "../crates/ledger-config"
|
||||||
|
|
||||||
@@ -105,3 +109,17 @@ path = "fuzz_targets/fuzz_settings_replay.rs"
|
|||||||
test = false
|
test = false
|
||||||
doc = false
|
doc = false
|
||||||
bench = 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
|
||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,27 +2,112 @@
|
|||||||
// balanced pairs, foreign-currency lines, periods, and closes, with
|
// balanced pairs, foreign-currency lines, periods, and closes, with
|
||||||
// amounts built from raw decimal bits (huge, tiny, odd scales). Refusals
|
// amounts built from raw decimal bits (huge, tiny, odd scales). Refusals
|
||||||
// are fine; panics, hangs, and broken books are not. After any sequence:
|
// are fine; panics, hangs, and broken books are not. After any sequence:
|
||||||
// the trial balance balances, every posted entry validates, and no draft
|
// the trial balance balances, every posted entry validates, no draft sits
|
||||||
// sits in a closed period (the Lean invariant Inv).
|
// 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]
|
#![no_main]
|
||||||
|
|
||||||
use arbitrary::Arbitrary;
|
use arbitrary::Arbitrary;
|
||||||
use chrono::NaiveDate;
|
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 libfuzzer_sys::fuzz_target;
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
|
|
||||||
#[derive(Arbitrary, Debug, Clone, Copy)]
|
#[derive(Arbitrary, Debug, Clone, Copy)]
|
||||||
enum Op {
|
enum Op {
|
||||||
AddAccount { code: u8, typ: u8, placeholder: bool },
|
AddAccount {
|
||||||
CreateEntry { day: u8 },
|
code: u8,
|
||||||
AddLine { entry: u8, account: u8, debit: bool, amount: Amount },
|
typ: u8,
|
||||||
Pair { entry: u8, debit: u8, credit: u8, amount: Amount },
|
placeholder: bool,
|
||||||
AddLineFx { entry: u8, account: u8, debit: bool, foreign: Amount, rate: Amount },
|
},
|
||||||
PostEntry { entry: u8 },
|
CreateEntry {
|
||||||
ReverseEntry { entry: u8, day: u8 },
|
day: u8,
|
||||||
CreatePeriod { start: u8, len: u8 },
|
},
|
||||||
ClosePeriod { period: 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.
|
/// 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.
|
// Mostly ordinary money, so entries balance and post.
|
||||||
return Decimal::new(i64::from(self.lo % 1_000_000), 2);
|
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>| {
|
fuzz_target!(|ops: Vec<Op>| {
|
||||||
let mut l = Ledger::new(Currency::USD);
|
let mut l = Ledger::new(Currency::USD);
|
||||||
let mut entries: Vec<u64> = Vec::new();
|
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();
|
let pick = |v: &[u64], i: u8| v.get(i as usize % v.len().max(1)).copied();
|
||||||
for op in ops.into_iter().take(200) {
|
for op in ops.into_iter().take(200) {
|
||||||
let accounts: Vec<u64> = l.chart().all().map(|a| a.id).collect();
|
let accounts: Vec<u64> = l.chart().all().map(|a| a.id).collect();
|
||||||
match op {
|
match op {
|
||||||
Op::AddAccount { code, typ, placeholder } => {
|
Op::AddAccount {
|
||||||
let _ = l.add_account(&format!("{}", 1000 + u32::from(code % 40)), "f", account_type(typ), None, placeholder);
|
code,
|
||||||
|
typ,
|
||||||
|
placeholder,
|
||||||
|
} => {
|
||||||
|
let _ = l.add_account(
|
||||||
|
&format!("{}", 1000 + u32::from(code % 40)),
|
||||||
|
"f",
|
||||||
|
account_type(typ),
|
||||||
|
None,
|
||||||
|
placeholder,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Op::CreateEntry { day: d } => {
|
Op::CreateEntry { day: d } => {
|
||||||
if let Ok(id) = l.try_create_entry(day(d), "f") {
|
if let Ok(id) = l.try_create_entry(day(d), "f") {
|
||||||
entries.push(id);
|
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)) {
|
if let (Some(e), Some(a)) = (pick(&entries, entry), pick(&accounts, account)) {
|
||||||
let side = if debit { Side::Debit } else { Side::Credit };
|
let side = if debit { Side::Debit } else { Side::Credit };
|
||||||
let _ = l.add_line(e, a, side, Money::new(amount.get(), Currency::USD));
|
let _ = l.add_line(e, a, side, Money::new(amount.get(), Currency::USD));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Op::Pair { entry, debit, credit, amount } => {
|
Op::Pair {
|
||||||
if let (Some(e), Some(a), Some(b)) =
|
entry,
|
||||||
(pick(&entries, entry), pick(&accounts, debit), pick(&accounts, credit))
|
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 m = Money::new(amount.get(), Currency::USD);
|
||||||
let _ = l.add_line(e, a, Side::Debit, m);
|
let _ = l.add_line(e, a, Side::Debit, m);
|
||||||
let _ = l.add_line(e, b, Side::Credit, 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)) {
|
if let (Some(e), Some(a)) = (pick(&entries, entry), pick(&accounts, account)) {
|
||||||
let side = if debit { Side::Debit } else { Side::Credit };
|
let side = if debit { Side::Debit } else { Side::Credit };
|
||||||
let _ = l.add_line_fx(e, a, side, Currency::EUR, foreign.get(), rate.get());
|
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 } => {
|
Op::CreatePeriod { start, len } => {
|
||||||
let _ = l.create_period("p", day(start), day(start.saturating_add(len % 60)));
|
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 } => {
|
Op::ClosePeriod { period } => {
|
||||||
let ids: Vec<u64> = l.list_periods().iter().map(|p| p.id).collect();
|
let ids: Vec<u64> = l.list_periods().iter().map(|p| p.id).collect();
|
||||||
if let Some(p) = pick(&ids, period) {
|
if let Some(p) = pick(&ids, period) {
|
||||||
@@ -122,13 +405,34 @@ fuzz_target!(|ops: Vec<Op>| {
|
|||||||
let tb = ledger_core::reports::trial_balance(&l);
|
let tb = ledger_core::reports::trial_balance(&l);
|
||||||
assert_eq!(tb.total_debits, tb.total_credits, "trial balance broken");
|
assert_eq!(tb.total_debits, tb.total_credits, "trial balance broken");
|
||||||
for e in l.posted_entries() {
|
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() {
|
for id in 1..l.next_entry_id() {
|
||||||
if let Some(e) = l.get_entry(id)
|
if let Some(e) = l.get_entry(id)
|
||||||
&& e.status() == EntryStatus::Draft
|
&& 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"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
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
|
@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
|
# 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
|
# 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)
|
# tested directly instead: it never writes the books (tests/read_only.rs)
|
||||||
|
|||||||
+2
-2
@@ -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)
|
#### 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.
|
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.
|
3. **Feature and schema:** none.
|
||||||
4. **Build:**
|
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`.
|
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.
|
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.
|
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)
|
#### P11b: the agent dock and books homes (D-27)
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user