diff --git a/crates/ledger-cli/tests/local_server.rs b/crates/ledger-cli/tests/local_server.rs index 8e9f7bb..96a9344 100644 --- a/crates/ledger-cli/tests/local_server.rs +++ b/crates/ledger-cli/tests/local_server.rs @@ -1466,6 +1466,21 @@ async fn every_call_is_recorded_once_refusals_included() { let snap = ledger_store::Snapshot::load(&home.book_dir("acme")).unwrap(); assert_eq!(heads.last().unwrap().1, snap.head_hash()); assert_eq!(added["client"]["name"], "rmcp"); + // Each record carries its request's own id, as the client numbered + // it: one connection's calls, oldest first, climb, none twice. + let ids: Vec = tools + .iter() + .rev() + .filter(|r| r["principal"]["label"] != "reader") + .map(|r| { + r["request_id"] + .as_str() + .and_then(|id| id.parse().ok()) + .unwrap_or_else(|| panic!("{r}")) + }) + .collect(); + assert!(ids.len() >= 8, "{ids:?}"); + assert!(ids.windows(2).all(|w| w[0] < w[1]), "{ids:?}"); let door: Vec<&Value> = records .iter() .filter(|r| r["action"] == "connect") @@ -2721,3 +2736,409 @@ async fn a_file_the_books_make_is_fetched_by_its_own_token() { drop(c); assert!(server.close().success()); } + +// Mutation survivors of local.rs (P2): the close, the waits, the admin +// line, adoption's settings and vault, and the peer check. + +/// INVARIANT 6: once closing, every request is refused as closing, a +/// route for no books included (never a connection cut it couldn't tell +/// from a call that ran), and the door stays open refusing while they +/// keep coming (LINGER_MAX, 3 s, after the work is done); then the +/// sockets go. Anything but a books route is simply not found. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn while_closing_the_door_stays_open_refusing_as_requests_come() { + let home = Home::new("linger"); + let mut server = Server::start(&home); + home.create("acme", "USD"); + let http = mcp_http(&home); + let r = http + .get("http://localhost/b/acme/nothing") + .send() + .await + .unwrap(); + assert_eq!(r.status().as_u16(), 404); + assert_eq!(r.text().await.unwrap(), "not found"); + drop(server.child.stdin.take()); + let (mut first, mut last) = (None, None); + let started = Instant::now(); + loop { + assert!(started.elapsed() < Duration::from_secs(60), "never shut"); + let Ok(r) = http + .post("http://localhost/b/nobody/mcp") + .header("content-type", "application/json") + .body("{}") + .send() + .await + else { + break; + }; + let status = r.status().as_u16(); + let text = r.text().await.unwrap_or_default(); + if text.contains("closing") { + assert_eq!(status, 503, "{text}"); + first.get_or_insert_with(Instant::now); + last = Some(Instant::now()); + } else { + // Not found, until closing; never again after. + assert!(first.is_none(), "after closing: {status} {text}"); + assert_eq!(status, 404, "{text}"); + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + let first = first.expect("never refused as closing"); + let open = last.unwrap() - first; + assert!(open >= Duration::from_millis(2500), "{open:?}"); + let status = tokio::task::spawn_blocking(move || wait_exit(&mut server.child, 30)) + .await + .unwrap(); + assert!(status.is_some_and(|s| s.success()), "{status:?}"); +} + +/// INVARIANT 6: standard input that can't be read (an error, not a read +/// interrupted) counts as its end, the host gone: the server closes. +#[test] +fn a_server_whose_input_cant_be_read_closes() { + let home = Home::new("badstdin"); + let server = Server::start(&home); + home.create("acme", "USD"); + assert!(server.close().success()); + // A folder as its input: every read of it fails (EISDIR). + let mut child = Command::new(env!("CARGO_BIN_EXE_wanderbooks")) + .args(["server", "--root"]) + .arg(home.root()) + .env_clear() + .env("HOME", home.dir.path()) + .stdin(std::fs::File::open(home.dir.path()).unwrap()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let status = wait_exit(&mut child, 30); + if status.is_none() { + let _ = child.kill(); + let _ = child.wait(); + } + assert!(status.is_some_and(|s| s.success()), "{status:?}"); + let mut store = ledger_store::Store::open(&home.book_dir("acme")).unwrap(); + store.verify_and_heal().unwrap(); +} + +/// Lines a child writes on stderr, as they come. +fn stderr_lines(child: &mut Child) -> std::sync::mpsc::Receiver { + let (tx, rx) = std::sync::mpsc::channel(); + let err = child.stderr.take().unwrap(); + std::thread::spawn(move || { + for line in BufReader::new(err).lines() { + let Ok(line) = line else { break }; + if tx.send(line).is_err() { + break; + } + } + }); + rx +} + +/// INVARIANT 1: a server waiting for another to finish says so, once, +/// however long it waits. +#[test] +fn a_server_waiting_for_another_says_so_once() { + let home = Home::new("saywait"); + let first = Server::start(&home); + let mut second = Command::new(env!("CARGO_BIN_EXE_wanderbooks")) + .args(["server", "--root"]) + .arg(home.root()) + .env_clear() + .env("HOME", home.dir.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let lines = stderr_lines(&mut second); + let waiting = "another books server has these books open; waiting for it to finish"; + let said = lines.recv_timeout(Duration::from_secs(30)); + if !said.as_ref().is_ok_and(|l| l.contains(waiting)) { + let _ = second.kill(); + let _ = second.wait(); + panic!("it never said it was waiting: {said:?}"); + } + // Many more tries at the lock (one each 100 ms), said nothing more. + std::thread::sleep(Duration::from_millis(1000)); + drop(second.stdin.take()); + assert!(wait_exit(&mut second, 20).is_some_and(|s| s.success())); + let rest: Vec = lines.iter().collect(); + assert!(!rest.iter().any(|l| l.contains(waiting)), "{rest:?}"); + assert!(first.close().success()); +} + +/// admin INVARIANT 1: a request is one line of at most MAX_LINE bytes +/// before its newline: one that long is answered, a byte more is refused +/// as too long. +#[test] +fn an_admin_request_may_be_as_long_as_a_line_may_be() { + let home = Home::new("adminline"); + let server = Server::start(&home); + let sock = home.run().join("admin.sock"); + let request = "{\"proto\":1,\"op\":\"list\"}"; + let padded = |len: usize| format!("{request}{}\n", " ".repeat(len - request.len())); + let answer = raw_admin(&sock, &padded(admin::MAX_LINE)); + let answered: Response = serde_json::from_str(answer.trim_end()) + .unwrap_or_else(|e| panic!("{e}: {}", &answer[..answer.len().min(200)])); + assert_eq!(answered, Response::Books(vec![])); + // A byte more and no newline yet: all of it read, refused. + let longer = padded(admin::MAX_LINE + 1); + let answer = raw_admin(&sock, longer.trim_end_matches('\n')); + assert!(answer.contains("too long"), "{answer}"); + assert!(server.close().success()); +} + +/// Old books that keep settings of their own (a `settings.set` in their +/// journal), in `dir`. +fn old_books_keeping_settings(dir: &Path) -> PathBuf { + let data = old_books(dir); + let mut store = ledger_store::Store::open(&data).unwrap(); + store + .change_settings( + &[ledger_core::settings::Change { + key: "company.name".into(), + value: Some(json!("Kept LLC")), + }], + &ledger_core::settings::Source { + decided_by: "the owner".into(), + decided_on: ledger_core::settings::today_in(None), + citation: None, + note: None, + }, + ) + .unwrap(); + data +} + +/// Adoption (admin INVARIANTS 4, 5): books that keep no settings need +/// their file, even for a preview; a file naming the very key given is +/// taken; every vault record, TINs and W-9s, is opened and counted; and a +/// file given for books that keep their own is reported unused, never +/// when none was given. +#[test] +fn adoption_takes_the_settings_and_the_vault_as_they_are() { + let home = Home::new("adoptsv"); + let server = Server::start(&home); + let old = tempfile::Builder::new() + .prefix("wb-srv-oldsv-") + .tempdir() + .unwrap(); + let (data, vault, key) = old_books_with_vault(old.path()); + ledger_vault::Vault::open(&vault, &key, false) + .unwrap() + .put_w9( + 1, + &ledger_vault::W9File { + file_name: "w9.pdf".into(), + mime_type: "application/pdf".into(), + bytes: b"%PDF-1.4 w9".to_vec(), + }, + ) + .unwrap(); + let vaulted = Adopt { + vault: Some((vault.clone(), key.clone())), + ..Adopt::new("acme", &data) + }; + match home.admin(vaulted.clone().request()) { + Response::Refused(why) => { + assert!(why.contains("keep no settings of their own"), "{why}"); + } + other => panic!("{other:?}"), + } + let toml = format!( + "[company]\nname = \"Acme\"\n[vault]\nkey_file = \"{}\"\n", + key.display() + ); + let named = Adopt { + settings_toml: Some(toml), + ..vaulted + }; + match home.admin(named.request()) { + Response::Preview(p) => assert_eq!(p.vault_records, 2, "{p:?}"), + other => panic!("{other:?}"), + } + let toml = "[company]\nname = \"Acme\"\n".to_string(); + for (id, given, ignored) in [("beta", Some(toml), true), ("gamma", None, false)] { + let dir = tempfile::Builder::new() + .prefix("wb-srv-kept-") + .tempdir() + .unwrap(); + let plan = Adopt { + settings_toml: given, + ..Adopt::new(id, &old_books_keeping_settings(dir.path())) + }; + let head = match home.admin(plan.clone().request()) { + Response::Preview(p) => { + assert!(p.keeps_settings); + p.source_head + } + other => panic!("{id}: {other:?}"), + }; + match home.admin(plan.at(&head).request()) { + Response::Adopted(a) => { + assert_eq!( + (a.settings_ignored, a.settings_imported), + (ignored, 0), + "{id}" + ); + } + other => panic!("{id}: {other:?}"), + } + } + assert!(server.close().success()); +} + +/// INVARIANT 2: a connection from another account is dropped before a +/// byte is read, even with the sockets' permissions loosened (as a slip +/// might). The other account is made in a user namespace, as a user ID +/// of this account's /etc/subuid range; where that can't be made (no +/// `unshare --map-auto` or python3 here), the test says so and skips. +#[test] +fn a_connection_from_another_account_is_dropped_unread() { + let home = Home::new("otheruid"); + let server = Server::start(&home); + std::fs::set_permissions(home.run(), std::fs::Permissions::from_mode(0o711)).unwrap(); + for sock in ["mcp.sock", "admin.sock"] { + std::fs::set_permissions( + home.run().join(sock), + std::fs::Permissions::from_mode(0o666), + ) + .unwrap(); + } + // Connect (from the run folder), send, read to the end: what came. + let script = "import socket, sys\n\ + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)\n\ + s.connect(sys.argv[1])\n\ + print('connected', flush=True)\n\ + got = b''\n\ + try:\n \ + s.sendall(sys.argv[2].encode())\n \ + while True:\n \ + b = s.recv(65536)\n \ + if not b:\n \ + break\n \ + got += b\n\ + except OSError:\n \ + pass\n\ + print('answer:' + got.decode(errors='replace'))\n"; + let ask = |other: bool, sock: &str, request: &str| -> Option { + let mut cmd = if other { + let mut c = Command::new("unshare"); + c.args([ + "--user", + "--map-auto", + "--setuid", + "0", + "--setgid", + "0", + "--", + "python3", + ]); + c + } else { + Command::new("python3") + }; + let out = cmd + .args(["-I", "-c", script, sock, request]) + .current_dir(home.run()) + .env_clear() + .env("PATH", "/usr/bin:/bin") + .stderr(Stdio::null()) + .output() + .ok()?; + let text = String::from_utf8_lossy(&out.stdout).into_owned(); + text.contains("connected\n").then_some(text) + }; + let http = "GET /nothing HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; + let list = "{\"proto\":1,\"op\":\"list\"}\n"; + // This account: answered, on both sockets. + let Some(ours) = ask(false, "mcp.sock", http) else { + eprintln!("SKIPPED a_connection_from_another_account_is_dropped_unread: no python3"); + return; + }; + assert!(ours.contains("404"), "{ours}"); + assert!(ask(false, "admin.sock", list).is_some_and(|a| a.contains("books"))); + // Another account: connected, and dropped unanswered. + for (sock, request) in [("mcp.sock", http), ("admin.sock", list)] { + let Some(theirs) = ask(true, sock, request) else { + eprintln!( + "SKIPPED a_connection_from_another_account_is_dropped_unread: no other account can be made here (unshare --map-auto)" + ); + return; + }; + assert!(theirs.ends_with("answer:\n"), "{sock}: {theirs}"); + } + // And this account is still served. + assert!(ask(false, "admin.sock", list).is_some_and(|a| a.contains("books"))); + assert!(server.close().success()); +} + +/// Out of descriptors, the server says so and waits a moment before +/// accepting again (never spinning), and serves again once some are free. +#[test] +fn a_server_out_of_descriptors_waits_then_serves_again() { + use std::os::unix::process::CommandExt; + let home = Home::new("nofile"); + let mut server = Command::new(env!("CARGO_BIN_EXE_wanderbooks")); + server + .args(["server", "--root"]) + .arg(home.root()) + .env_clear() + .env("HOME", home.dir.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + // SAFETY: setrlimit is async-signal-safe; nothing else runs here. + unsafe { + server.pre_exec(|| { + let limit = libc::rlimit { + rlim_cur: 40, + rlim_max: 40, + }; + if libc::setrlimit(libc::RLIMIT_NOFILE, &limit) != 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + let mut child = server.spawn().unwrap(); + let mut line = String::new(); + BufReader::new(child.stdout.take().unwrap()) + .read_line(&mut line) + .unwrap(); + assert_eq!(line, "ready\n"); + let lines = stderr_lines(&mut child); + let short = ledger_store::root::SocketPath::new(&home.run(), "mcp.sock").unwrap(); + let held: Vec<_> = (0..80) + .map(|_| std::os::unix::net::UnixStream::connect(short.path()).unwrap()) + .collect(); + let said = std::iter::from_fn(|| lines.recv_timeout(Duration::from_secs(30)).ok()) + .find(|l| l.contains("accept failed")); + if said.is_none() { + let _ = child.kill(); + let _ = child.wait(); + panic!("out of descriptors, it never said so"); + } + drop(held); + // Served again once descriptors are free (the admin socket too). + let deadline = Instant::now() + Duration::from_secs(30); + loop { + if let Ok(Response::Books(b)) = admin::call(&home.run().join("admin.sock"), Request::List) { + assert!(b.is_empty()); + break; + } + assert!(Instant::now() < deadline, "never served again"); + std::thread::sleep(Duration::from_millis(100)); + } + drop(child.stdin.take()); + let status = wait_exit(&mut child, 30); + if status.is_none() { + let _ = child.kill(); + let _ = child.wait(); + } + assert!(status.is_some_and(|s| s.success()), "{status:?}"); +} diff --git a/crates/ledger-cli/tests/one_executable.rs b/crates/ledger-cli/tests/one_executable.rs index f6f4104..2b2e583 100644 --- a/crates/ledger-cli/tests/one_executable.rs +++ b/crates/ledger-cli/tests/one_executable.rs @@ -296,3 +296,143 @@ fn dev_stdio_refuses_books_in_the_private_folder() { "{out:?}" ); } + +/// `upload` and `download` say how they ended in their exit code, as +/// every mode does: 0 for their usage on request, 2 for a slip in how +/// they were called, 1 for what went wrong doing it. A file's path may be +/// a name that isn't text: it reaches the command as given. +#[cfg(unix)] +#[test] +fn the_file_commands_end_with_the_code_that_says_how() { + use std::os::unix::ffi::OsStrExt; + let home = tempfile::Builder::new() + .prefix("wb-one-files-") + .tempdir() + .unwrap(); + let root = home.path().join("wanderbooks"); + let run = |args: &[&std::ffi::OsStr]| { + wanderbooks() + .args(args) + .env_remove("XDG_DATA_HOME") + .stdin(Stdio::null()) + .output() + .unwrap() + }; + let os = |s: &'static str| std::ffi::OsStr::new(s); + for (mode, usage) in [ + ("upload", "usage: wanderbooks upload --book ID"), + ("download", "usage: wanderbooks download --book ID"), + ] { + // Its usage on request. + let out = run(&[os(mode), os("--help")]); + assert_eq!(out.status.code(), Some(0), "{mode}: {out:?}"); + assert!( + String::from_utf8_lossy(&out.stderr).contains(usage), + "{out:?}" + ); + // Called wrong: no file, or no books named. + let out = run(&[ + os(mode), + os("--root"), + root.as_os_str(), + os("--book"), + os("acme"), + ]); + assert_eq!(out.status.code(), Some(2), "{mode}: {out:?}"); + assert!( + String::from_utf8_lossy(&out.stderr).contains(usage), + "{out:?}" + ); + let out = run(&[os(mode), os("--root"), root.as_os_str(), os("1")]); + assert_eq!(out.status.code(), Some(2), "{mode}: {out:?}"); + assert!( + String::from_utf8_lossy(&out.stderr).contains("which books? give --book ID"), + "{out:?}" + ); + } + // A file that isn't there, named by a path that isn't text: what went + // wrong, said, and nothing sent. + let missing = home + .path() + .join(std::ffi::OsStr::from_bytes(b"receipt-\xff.pdf")); + let out = run(&[ + os("upload"), + os("--root"), + root.as_os_str(), + os("--book"), + os("acme"), + os("up_abcdefghijklmnopabcdefghijklmnop"), + missing.as_os_str(), + ]); + assert_eq!(out.status.code(), Some(1), "{out:?}"); + assert!( + String::from_utf8_lossy(&out.stderr).contains("error: can't read"), + "{out:?}" + ); + // A folder to save in that isn't there: the same. + let out = run(&[ + os("download"), + os("--root"), + root.as_os_str(), + os("--book"), + os("acme"), + os("7"), + os("--to"), + home.path().join("no-such-folder").as_os_str(), + ]); + assert_eq!(out.status.code(), Some(1), "{out:?}"); + assert!( + String::from_utf8_lossy(&out.stderr).contains("isn't a folder"), + "{out:?}" + ); + assert!(!root.exists(), "the books were reached for"); +} + +/// Where local sockets aren't built yet (P11, Windows): the modes that +/// hold the books say so and end with 2, never a success; setup makes +/// the folder and ends with 0, asking for no passphrase (nothing there +/// asks for one yet). +#[cfg(not(unix))] +#[test] +fn without_local_sockets_the_modes_that_hold_the_books_say_so() { + for mode in [ + "server", + "host", + "mcp", + "serve", + "close", + "books", + "doctor", + "token", + "connect", + "passphrase", + "upload", + "download", + ] { + let out = wanderbooks() + .arg(mode) + .stdin(Stdio::null()) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(2), "{mode}: {out:?}"); + assert!( + String::from_utf8_lossy(&out.stderr).contains("to this system with P11"), + "{mode}: {out:?}" + ); + } + let home = tempfile::Builder::new() + .prefix("wb-one-nosock-") + .tempdir() + .unwrap(); + let out = wanderbooks() + .args(["setup", "--home"]) + .arg(home.path()) + .env_remove("XDG_DATA_HOME") + .stdin(Stdio::null()) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(0), "{out:?}"); + let text = String::from_utf8_lossy(&out.stdout); + assert!(text.contains("The books' private folder: made"), "{text}"); + assert!(!text.contains("passphrase"), "{text}"); +} diff --git a/crates/ledger-mcp/src/admin.rs b/crates/ledger-mcp/src/admin.rs index c8da8cf..bba6ad4 100644 --- a/crates/ledger-mcp/src/admin.rs +++ b/crates/ledger-mcp/src/admin.rs @@ -462,6 +462,13 @@ pub fn call_within( if answer.is_empty() { return Err(unknown("the books server stopped before answering".into())); } + // INVARIANT 1: an answer is one line of at most MAX_LINE bytes; a + // longer one, or one cut off, is refused, even if what came parses. + if !answer.ends_with('\n') { + return Err(unknown( + "the books server's answer is too long, or cut off".into(), + )); + } serde_json::from_str(answer.trim_end()) .map_err(|e| unknown(format!("the books server's answer isn't understood ({e})"))) } @@ -619,6 +626,8 @@ mod tests { } ); assert!(!shown.contains("wb_secret"), "{shown}"); + // The text is there, and hidden: never shown as nothing. + assert!(shown.contains("text: TokenText(…)"), "{shown}"); let r: Envelope = serde_json::from_value(json!({"proto": 1, "op": "token_list"})).unwrap(); assert_eq!(r.request, Request::TokenList); assert!( @@ -648,4 +657,100 @@ mod tests { assert!(!text.contains(&t.sha256)); assert!(text.contains("books:read")); } + + /// Why an admin call has no answer is what the command line shows. + #[test] + fn a_call_error_says_why() { + assert_eq!( + CallError::NotSent("the books server isn't running".into()).to_string(), + "the books server isn't running" + ); + assert_eq!( + CallError::Unknown("no answer came".into()).to_string(), + "no answer came" + ); + } + + /// A fake books server on the socket `name` in `dir`: it takes one + /// request and answers `answer`, byte for byte. + #[cfg(unix)] + fn answering( + dir: &std::path::Path, + name: &str, + answer: Vec, + ) -> (std::path::PathBuf, std::thread::JoinHandle<()>) { + let socket = dir.join(name); + let listener = std::os::unix::net::UnixListener::bind(&socket).unwrap(); + let server = std::thread::spawn(move || { + use std::io::{BufRead, Write}; + let (stream, _) = listener.accept().unwrap(); + let mut request = String::new(); + std::io::BufReader::new(&stream) + .read_line(&mut request) + .unwrap(); + // A client that stops reading part way breaks the pipe: fine. + let _ = (&stream).write_all(&answer); + }); + (socket, server) + } + + /// A refusal padded with spaces to `len` bytes, then its newline. + fn padded(len: usize) -> Vec { + let mut answer = br#"{"refused":"no"}"#.to_vec(); + answer.resize(len, b' '); + answer.push(b'\n'); + answer + } + + /// INVARIANT 1, the command line's side: an answer of 4 MiB, its + /// newline not counted, is taken whole (settings texts ride in them); + /// a byte more is refused, as is an answer cut off before its + /// newline, even when what came parses. + #[cfg(unix)] + #[test] + fn an_answer_is_one_line_of_at_most_four_mib() { + let four_mib = 4 * 1024 * 1024; + let dir = tempfile::tempdir().unwrap(); + let (socket, server) = answering(dir.path(), "whole.sock", padded(four_mib)); + assert_eq!( + call(&socket, Request::List), + Ok(Response::Refused("no".into())) + ); + server.join().unwrap(); + let too_long_or_cut = Err(CallError::Unknown( + "the books server's answer is too long, or cut off".into(), + )); + let (socket, server) = answering(dir.path(), "long.sock", padded(four_mib + 1)); + assert_eq!(call(&socket, Request::List), too_long_or_cut); + server.join().unwrap(); + let (socket, server) = answering(dir.path(), "cut.sock", br#"{"refused":"no"}"#.into()); + assert_eq!(call(&socket, Request::List), too_long_or_cut); + server.join().unwrap(); + } + + /// No server on the socket: the request never left, and the command + /// line says the server isn't running, whether there is no socket or + /// one a stopped server left behind. + #[cfg(unix)] + #[test] + fn no_server_means_nothing_was_sent() { + let dir = tempfile::tempdir().unwrap(); + let not_running = Err(CallError::NotSent("the books server isn't running".into())); + assert_eq!( + call(&dir.path().join("admin.sock"), Request::List), + not_running + ); + let stale = dir.path().join("stale.sock"); + drop(std::os::unix::net::UnixListener::bind(&stale).unwrap()); + assert_eq!( + call( + &stale, + Request::Create { + id: "acme".into(), + currency: "USD".into(), + } + ), + not_running + ); + } } diff --git a/crates/ledger-mcp/src/local.rs b/crates/ledger-mcp/src/local.rs index 2948133..b10a1e9 100644 --- a/crates/ledger-mcp/src/local.rs +++ b/crates/ledger-mcp/src/local.rs @@ -2452,4 +2452,528 @@ mod tests { assert!(peer_uid(&file).is_err()); assert!(!peer_is_us(&file)); } + + /// A socket with no other end: the system answers for it (Linux: no + /// process, no user), and that answer names neither this process nor + /// this account; the client's check refuses it. + #[test] + fn a_socket_with_no_other_end_names_no_process_and_isnt_us() { + use std::os::fd::FromRawFd; + // SAFETY: socket(2) has no preconditions; the descriptor is checked + // and then owned by the stream alone. + let fd = unsafe { libc::socket(libc::AF_UNIX, libc::SOCK_STREAM | libc::SOCK_CLOEXEC, 0) }; + assert!(fd >= 0); + // SAFETY: a fresh descriptor of a Unix stream socket, owned by none. + let lone = unsafe { std::os::unix::net::UnixStream::from_raw_fd(fd) }; + assert_eq!(peer_pid(&lone), None); + assert!(!peer_is_us(&lone)); + assert_eq!( + check_peer_std(&lone).unwrap_err(), + "the books server's socket belongs to another account: not used" + ); + } + + /// A session ends only after a day without a message (SESSION_IDLE), + /// never rmcp's few minutes: a call longer than that keeps its answer. + #[test] + fn a_session_ends_only_after_a_day_without_a_message() { + assert_eq!( + sessions().session_config.keep_alive, + Some(Duration::from_secs(86_400)) + ); + } + + /// Settings moved in at adoption are dated in the time zone the file + /// sets (company.timezone), not by any other entry's text. The two + /// zones are 26 hours apart, so their dates always differ. + #[test] + fn settings_moved_in_are_dated_in_the_files_own_time_zone() { + let change = |key: &str, value: &str| ledger_core::settings::Change { + key: key.into(), + value: Some(serde_json::json!(value)), + }; + let changes = [ + change("company.name", "Etc/GMT+12"), + change("company.timezone", "Pacific/Kiritimati"), + ]; + let source = moved_in_at_adoption(&changes); + assert_eq!( + source.decided_on, + ledger_core::settings::today_in(Some("Pacific/Kiritimati")) + ); + assert_ne!( + source.decided_on, + ledger_core::settings::today_in(Some("Etc/GMT+12")) + ); + assert_eq!(source.decided_by, "the settings file"); + } + + /// A server's books in a fresh root under `home`, as `run` holds them, + /// without its sockets: each test drives the handlers itself. + fn books_in(home: &Path) -> Arc { + let (root, _) = Root::setup(home, Some(&home.join("root")), None).unwrap(); + 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(()), + }); + let loaded = books.load_principals(); + *books.principals.write().unwrap() = loaded; + books + } + + /// Make the books `id` through the admin request. + fn create(books: &Books, id: &str) { + match books.admin(Request::Create { + id: id.into(), + currency: "USD".into(), + }) { + Response::Created { problem: None, .. } => {} + other => panic!("{other:?}"), + } + } + + /// A token for `book` named `name` with every scope (made, or made + /// again): its id and its text. + fn token(books: &Books, book: &str, name: &str) -> (String, String) { + match books.admin(Request::TokenCreate { + book: book.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:?}"), + } + } + + /// The sessions bound, by id. + fn bound(books: &Books) -> Vec { + books + .session_tokens + .lock() + .unwrap() + .keys() + .cloned() + .collect() + } + + /// The status, the refusal code and the text of an answer. + async fn answer(r: axum::response::Response) -> (u16, Option, String) { + let code = r + .headers() + .get(REFUSED_HEADER) + .map(|v| v.to_str().unwrap().to_string()); + let status = r.status().as_u16(); + let body = axum::body::to_bytes(r.into_body(), usize::MAX) + .await + .unwrap(); + (status, code, String::from_utf8_lossy(&body).into_owned()) + } + + /// A PUT of `body` for upload `id` of the books `acme`. + fn put(id: &str, bearer: Option<&str>, body: axum::body::Body) -> axum::extract::Request { + let mut r = axum::http::Request::builder() + .method("PUT") + .uri(format!("http://localhost/b/acme/u/{id}")); + if let Some(b) = bearer { + r = r.header("authorization", format!("Bearer {b}")); + } + r.body(body).unwrap() + } + + /// Send a PUT to the upload route as the router would. + async fn send_upload( + books: &Arc, + id: &str, + bearer: Option<&str>, + body: axum::body::Body, + ) -> axum::response::Response { + upload( + axum::extract::State(books.clone()), + axum::extract::Path(("acme".to_string(), id.to_string())), + put(id, bearer, body), + ) + .await + } + + /// A request body that arrives as the test sends it, one chunk at a + /// time (the sender's `reserve` returns once the last chunk was taken). + fn fed() -> (tokio::sync::mpsc::Sender>, axum::body::Body) { + let (tx, rx) = tokio::sync::mpsc::channel::>(1); + let stream = futures_util::stream::unfold(rx, |mut rx| async move { + rx.recv() + .await + .map(|b| (Ok::<_, std::io::Error>(axum::body::Bytes::from(b)), rx)) + }); + (tx, axum::body::Body::from_stream(stream)) + } + + /// The upload's grant, for `token_id`, of `bytes`. + fn grant(books: &Books, token_id: &str, bytes: &[u8]) -> ledger_store::uploads::Grant { + let held = books.held(); + let Some(Book::Open { uploads, .. }) = held.get("acme") else { + panic!("acme isn't open"); + }; + uploads + .grant( + token_id, + &ledger_store::journal::sha256_hex(bytes), + bytes.len() as u64, + "application/pdf", + "upload.pdf", + "document", + chrono::Utc::now().timestamp(), + ) + .unwrap() + } + + /// The last access record of `acme`: its action and its outcome. + fn last_access(books: &Books) -> (String, Outcome) { + let dir = books.root.book_dir(&BookId::parse("acme").unwrap()); + let (records, _) = ledger_store::access::read(&dir).unwrap(); + let last = records.last().unwrap(); + (last.access.action.clone(), last.access.result.clone()) + } + + #[test] + fn a_book_that_didnt_open_says_why_and_one_open_says_nothing() { + let why = "its access log can't be used".to_string(); + assert_eq!(Book::Failed(why.clone()).problem(), Some(why)); + let home = tempfile::tempdir().unwrap(); + let books = books_in(home.path()); + create(&books, "acme"); + assert_eq!(books.held()["acme"].problem(), None); + } + + /// INVARIANT 6: once closing, every request is refused as closing, + /// even one for books that didn't open or don't exist (until then + /// told so), so a client knows the server is going, not that its + /// books are gone. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn once_closing_every_request_is_refused_as_closing() { + let home = tempfile::tempdir().unwrap(); + let books = books_in(home.path()); + create(&books, "acme"); + books.hold( + &BookId::parse("broken").unwrap(), + Book::Failed("its access log can't be used".into()), + ); + let asked = Asked::connect(&axum::http::Method::POST); + let headers = axum::http::HeaderMap::new(); + let refused = |books: Arc, book: &'static str| { + let (asked, headers) = (&asked, &headers); + async move { + match door(&books, book, asked, headers).await { + Ok(_) => panic!("{book}: let through"), + Err(r) => answer(*r).await, + } + } + }; + let (status, code, text) = refused(books.clone(), "broken").await; + assert_eq!((status, code.as_deref()), (503, Some("not-open")), "{text}"); + let (status, code, text) = refused(books.clone(), "nobody").await; + assert_eq!( + (status, code.as_deref()), + (404, Some("no-such-books")), + "{text}" + ); + books.closing.cancel(); + for book in ["broken", "nobody"] { + let (status, code, text) = refused(books.clone(), book).await; + assert_eq!((status, code), (503, None), "{book}: {text}"); + assert_eq!(text, "the books are closing: this request wasn't run"); + } + let (status, code, text) = refused(books.clone(), "acme").await; + assert_eq!( + (status, code.as_deref()), + (503, Some(crate::access::CLOSING)) + ); + assert_eq!(text, "the books are closing: this request wasn't run"); + } + + /// INVARIANT 9: nothing comes in once the books stopped (SharedStore + /// INVARIANT 2): an upload is refused as stopped, and recorded so. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn an_upload_to_books_that_stopped_is_refused_as_stopped() { + let home = tempfile::tempdir().unwrap(); + let books = books_in(home.path()); + create(&books, "acme"); + let (id, text) = token(&books, "acme", "agent"); + let bytes = b"%PDF-1.4 stopped".to_vec(); + let g = grant(&books, &id, &bytes); + match books.held().get("acme") { + Some(Book::Open { store, .. }) => store.stop(), + _ => panic!("acme isn't open"), + } + let r = send_upload(&books, &g.id, Some(&text), bytes.into()).await; + let (status, code, said) = answer(r).await; + assert_eq!((status, code.as_deref()), (503, Some("stopped")), "{said}"); + assert!(said.contains("stopped taking requests"), "{said}"); + assert_eq!( + last_access(&books), + ("upload".to_string(), Outcome::Refused("stopped".into())) + ); + } + + /// INVARIANT 9: an upload is received from one request at a time; + /// a second sent while the first is still arriving is refused as in + /// use, and the first is kept. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn an_upload_still_arriving_is_refused_to_a_second_sender() { + let home = tempfile::tempdir().unwrap(); + let books = books_in(home.path()); + create(&books, "acme"); + let (id, text) = token(&books, "acme", "agent"); + let bytes: Vec = b"%PDF-1.4 " + .iter() + .copied() + .chain((0..2_000_000u32).map(|i| (i % 251) as u8)) + .collect(); + let g = grant(&books, &id, &bytes); + let (tx, body) = fed(); + let first = tokio::spawn({ + let (books, id, text) = (books.clone(), g.id.clone(), text.clone()); + async move { send_upload(&books, &id, Some(&text), body).await } + }); + tx.send(bytes[..1_000_000].to_vec()).await.unwrap(); + // Arriving: its part is on the disk. + let part = books + .root + .book_dir(&BookId::parse("acme").unwrap()) + .join(ledger_store::uploads::UPLOADS_DIR) + .join(format!("{}.part", g.id)); + let deadline = Instant::now() + Duration::from_secs(30); + while !part.exists() { + assert!(Instant::now() < deadline, "the first upload never began"); + tokio::time::sleep(Duration::from_millis(10)).await; + } + let second = send_upload(&books, &g.id, Some(&text), bytes.clone().into()).await; + let (status, code, said) = answer(second).await; + assert_eq!((status, code.as_deref()), (409, Some("in_use")), "{said}"); + assert_eq!( + last_access(&books), + ("upload".to_string(), Outcome::Refused("in_use".into())) + ); + tx.send(bytes[1_000_000..].to_vec()).await.unwrap(); + drop(tx); + let (status, _, said) = answer(first.await.unwrap()).await; + assert_eq!(status, 200, "{said}"); + } + + /// INVARIANT 9: a refused upload's bytes are read and dropped up to + /// the most an upload can be, so a client sending no more than that + /// is never cut off mid-send, and no further, so one that never stops + /// sending is still answered: refused at the door (no token) and past + /// it (no such upload) alike. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_refused_upload_is_read_up_to_the_most_an_upload_can_be() { + let home = tempfile::tempdir().unwrap(); + let books = books_in(home.path()); + create(&books, "acme"); + let (_, text) = token(&books, "acme", "agent"); + let max = ledger_store::uploads::MAX_UPLOAD as usize; + let mib = 1024 * 1024; + assert_eq!(max % mib, 0); + for (bearer, status, code) in [ + (None, 401, crate::access::UNAUTHORIZED), + (Some(text.clone()), 404, "no_such_upload"), + ] { + let (tx, body) = fed(); + let answered = tokio::spawn({ + let books = books.clone(); + async move { + let id = "up_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + send_upload(&books, id, bearer.as_deref(), body).await + } + }); + for _ in 0..max / mib { + tx.send(vec![b'x'; mib]).await.unwrap(); + } + // Every byte up to the most taken, and a moment to act on it: + // still reading. + let more = tx.reserve().await.unwrap(); + tokio::time::sleep(Duration::from_millis(500)).await; + assert!( + !answered.is_finished(), + "{status}: answered before the most an upload can be was read" + ); + // One byte past it: answered, though the body never ends. + more.send(vec![b'x']); + let r = tokio::time::timeout(Duration::from_secs(60), answered) + .await + .unwrap_or_else(|_| panic!("{status}: never answered")) + .unwrap(); + let (got, refused, said) = answer(r).await; + assert_eq!((got, refused.as_deref()), (status, Some(code)), "{said}"); + drop(tx); + } + } + + /// INVARIANT 8: a token revoked or replaced lets go of the sessions it + /// opened, and only those: not another book's token of its name, nor + /// its own book's token of another name. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_replaced_or_revoked_token_lets_go_of_its_own_sessions_alone() { + let home = tempfile::tempdir().unwrap(); + let books = books_in(home.path()); + create(&books, "acme"); + create(&books, "beta"); + // Oldest first: another book's token of the same name, then one of + // the same book with another name, then the one replaced. + let (beta, _) = token(&books, "beta", "agent"); + let (other, _) = token(&books, "acme", "other"); + let (agent, _) = token(&books, "acme", "agent"); + { + let mut bound = books.session_tokens.lock().unwrap(); + for (sid, owner) in [("s-beta", &beta), ("s-other", &other), ("s-agent", &agent)] { + bound.insert(sid.into(), owner.clone()); + } + } + token(&books, "acme", "agent"); + assert_eq!(bound(&books), ["s-beta", "s-other"]); + match books.admin(Request::TokenRevoke { + book: "beta".into(), + name: "agent".into(), + id: None, + }) { + Response::TokenRevoked(t) => assert_eq!(t.id, beta), + other => panic!("{other:?}"), + } + assert_eq!(bound(&books), ["s-other"]); + } + + /// An MCP request to `acme` with `bearer`, on `session` if given. + fn mcp_request( + method: axum::http::Method, + bearer: &str, + session: Option<&str>, + body: serde_json::Value, + ) -> axum::extract::Request { + let mut r = axum::http::Request::builder() + .method(method) + .uri("http://localhost/b/acme/mcp") + .header("host", "localhost") + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .header("authorization", format!("Bearer {bearer}")); + if let Some(sid) = session { + r = r + .header(SESSION_HEADER, sid) + .header("mcp-protocol-version", "2025-06-18"); + } + r.body(axum::body::Body::from(body.to_string())).unwrap() + } + + async fn send_mcp( + books: &Arc, + request: axum::extract::Request, + ) -> axum::response::Response { + mcp( + axum::extract::State(books.clone()), + axum::extract::Path("acme".to_string()), + request, + ) + .await + } + + fn initialize() -> serde_json::Value { + serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { + "protocolVersion": "2025-06-18", "capabilities": {}, + "clientInfo": {"name": "test", "version": "0"}}}) + } + + /// INVARIANT 8: a session stays bound to the token that opened it + /// through every request it serves, and is forgotten when it ends. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_session_stays_bound_to_its_token_until_it_ends() { + use axum::http::Method; + let home = tempfile::tempdir().unwrap(); + let books = books_in(home.path()); + create(&books, "acme"); + let (agent_id, agent) = token(&books, "acme", "agent"); + let (_, other) = token(&books, "acme", "other"); + let r = send_mcp( + &books, + mcp_request(Method::POST, &agent, None, initialize()), + ) + .await; + let sid = r.headers()[SESSION_HEADER].to_str().unwrap().to_string(); + let (status, _, said) = answer(r).await; + assert_eq!(status, 200, "{said}"); + let initialized = + serde_json::json!({"jsonrpc": "2.0", "method": "notifications/initialized"}); + let list = serde_json::json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}); + for body in [initialized, list.clone()] { + let r = send_mcp(&books, mcp_request(Method::POST, &agent, Some(&sid), body)).await; + let (status, _, said) = answer(r).await; + assert!((200..300).contains(&status), "{status}: {said}"); + assert_eq!( + books.session_tokens.lock().unwrap().get(&sid), + Some(&agent_id) + ); + } + let r = send_mcp(&books, mcp_request(Method::POST, &other, Some(&sid), list)).await; + let (status, code, _) = answer(r).await; + assert_eq!((status, code.as_deref()), (403, Some("session_token"))); + // Ended: forgotten. + let r = send_mcp( + &books, + mcp_request(Method::DELETE, &agent, Some(&sid), serde_json::json!({})), + ) + .await; + let (status, _, said) = answer(r).await; + assert!((200..300).contains(&status), "{status}: {said}"); + assert!(bound(&books).is_empty()); + } + + /// INVARIANT 8: past MAX_SESSIONS bindings only a request that opens + /// a session (an initialize) is refused; any other still reaches the + /// MCP service. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn past_the_most_sessions_only_opening_another_is_refused() { + use axum::http::Method; + let home = tempfile::tempdir().unwrap(); + let books = books_in(home.path()); + create(&books, "acme"); + let (agent_id, agent) = token(&books, "acme", "agent"); + { + let mut bound = books.session_tokens.lock().unwrap(); + for n in 0..MAX_SESSIONS { + bound.insert(format!("s-{n}"), agent_id.clone()); + } + } + let r = send_mcp( + &books, + mcp_request(Method::POST, &agent, None, initialize()), + ) + .await; + let (status, code, said) = answer(r).await; + assert_eq!( + (status, code.as_deref()), + (503, Some("too_many_sessions")), + "{said}" + ); + let list = serde_json::json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}); + let r = send_mcp(&books, mcp_request(Method::POST, &agent, None, list)).await; + let (status, code, said) = answer(r).await; + assert_ne!( + code.as_deref(), + Some("too_many_sessions"), + "{status}: {said}" + ); + assert_eq!(bound(&books).len(), MAX_SESSIONS); + } } diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index 49e685c..f7d5747 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -10274,4 +10274,459 @@ mod tests { ); assert_eq!(digest_since("", d(2027, 1, 9)), "2027-01-01"); } + + // Calls through the access door (access.rs), driven straight into the + // handler with a context the HTTP door would have left: its Caller, + // or none. + + /// A peer to make request contexts with: a client and a server over + /// an in-memory pipe, kept alive while it is. + struct Session { + peer: rmcp::service::Peer, + _server: rmcp::service::RunningService, + _client: rmcp::service::RunningService, + _books: TestDir, + } + + async fn session(tag: &str) -> Session { + use rmcp::ServiceExt; + let books = unit_dir(&format!("{tag}-peer")); + let server = BooksServer::new(store(&books), ReceiptPolicy::default()); + let (a, b) = tokio::io::duplex(1 << 16); + let served = tokio::spawn(async move { server.serve(a).await }); + let client = ().serve(b).await.unwrap(); + let server = served.await.unwrap().unwrap(); + Session { + peer: server.peer().clone(), + _server: server, + _client: client, + _books: books, + } + } + + /// A request's context: from a token with `scopes` (the door's + /// Caller), or with none. + fn context( + peer: &rmcp::service::Peer, + scopes: Option<&[ledger_store::auth::Scope]>, + ) -> RequestContext { + let mut cx = RequestContext::new(rmcp::model::NumberOrString::Number(7), peer.clone()); + if let Some(scopes) = scopes { + let token = ledger_store::auth::Token { + id: "tok_0123456789ab".into(), + book: "acme".into(), + name: "test".into(), + scopes: scopes.iter().copied().collect(), + created: "2026-10-20T00:00:00Z".into(), + sha256: String::new(), + }; + let (mut parts, ()) = axum::http::Request::new(()).into_parts(); + parts.extensions.insert(crate::access::Caller { token }); + cx.extensions.insert(parts); + } + cx + } + + const ALL: &[ledger_store::auth::Scope] = &[ + ledger_store::auth::Scope::Read, + ledger_store::auth::Scope::Write, + ledger_store::auth::Scope::Post, + ledger_store::auth::Scope::Admin, + ]; + + /// Books served from the data root: calls are checked against their + /// caller's token and recorded in the access log beside them. + fn logged(tag: &str) -> (BooksServer, TestDir) { + let books = unit_dir(tag); + let store = store(&books); + let log = ledger_store::access::AccessLog::open(&books.0).unwrap(); + let server = BooksServer::new(store, ReceiptPolicy::default()).with_access(Arc::new(log)); + (server, books) + } + + /// Books whose access log can't be written: its file is a FIFO no one + /// reads, so every record fails (as on a full disk). + fn unwritable(tag: &str) -> (BooksServer, TestDir) { + let books = unit_dir(tag); + let store = store(&books); + let fifo = books.0.join(ledger_store::access::ACCESS_FILE); + let made = std::process::Command::new("mkfifo") + .arg(&fifo) + .status() + .unwrap(); + assert!(made.success()); + let path = fifo.clone(); + let other_end = std::thread::spawn(move || { + // The log reads the file whole as it opens (empty: a writer + // that goes at once), then opens it to append (a reader that + // goes at once): nothing reads what is written after. + drop(std::fs::OpenOptions::new().write(true).open(&path).unwrap()); + drop(std::fs::File::open(&path).unwrap()); + }); + let log = ledger_store::access::AccessLog::open(&books.0).unwrap(); + other_end.join().unwrap(); + let server = BooksServer::new(store, ReceiptPolicy::default()).with_access(Arc::new(log)); + (server, books) + } + + /// The access log's records: (subject, outcome, whether it carries + /// the arguments' digest). + fn records(books: &TestDir) -> Vec<(String, ledger_store::access::Outcome, bool)> { + ledger_store::access::read(&books.0) + .unwrap() + .0 + .into_iter() + .map(|r| { + ( + r.access.subject, + r.access.result, + r.access.args_digest.is_some(), + ) + }) + .collect() + } + + fn refused(code: &str) -> ledger_store::access::Outcome { + ledger_store::access::Outcome::Refused(code.into()) + } + + fn tool_call(name: &str, args: serde_json::Value) -> rmcp::model::CallToolRequestParams { + rmcp::model::CallToolRequestParams::new(name.to_string()) + .with_arguments(args.as_object().cloned().unwrap_or_default()) + } + + /// A tool's answer: Ok((is it a refusal, its text)), or the protocol + /// error. + fn answer( + out: Result, + ) -> Result<(bool, String), rmcp::ErrorData> { + match out? { + rmcp::model::CallToolResponse::Complete(r) => Ok(( + r.is_error == Some(true), + r.content + .iter() + .find_map(|c| c.as_text().map(|t| t.text.clone())) + .unwrap_or_default(), + )), + other => panic!("not a complete answer: {other:?}"), + } + } + + fn rt() -> tokio::runtime::Runtime { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .unwrap() + } + + /// access.rs INVARIANTS 1-3: a call that reaches the books without a + /// Caller, or names a tool no table knows, is refused unrun (fail + /// closed), told why in words, and recorded so. + #[test] + fn a_call_without_a_caller_or_to_no_tool_is_refused_unrun_and_recorded() { + rt().block_on(async { + let s = session("door").await; + let (server, books) = logged("door"); + let add = || { + tool_call( + "books.add_account", + serde_json::json!({"code": "1000", "name": "Cash", "account_type": "asset"}), + ) + }; + let (refusal, text) = + answer(server.call_tool(add(), context(&s.peer, None)).await).unwrap(); + assert!(refusal, "{text}"); + assert!(text.contains("without a token"), "{text}"); + let unknown = tool_call("books.no_such_tool", serde_json::json!({})); + let (refusal, text) = + answer(server.call_tool(unknown, context(&s.peer, Some(ALL))).await).unwrap(); + assert!(refusal, "{text}"); + assert!(text.contains("there is no tool"), "{text}"); + // Neither ran: the account isn't there. + let chart = tool_call("books.chart", serde_json::json!({})); + let (refusal, text) = + answer(server.call_tool(chart, context(&s.peer, Some(ALL))).await).unwrap(); + assert!(!refusal, "{text}"); + assert!(!text.contains("Cash"), "{text}"); + assert_eq!( + records(&books), + vec![ + ("books.add_account".into(), refused("unauthorized"), true), + ("books.no_such_tool".into(), refused("unknown_tool"), true), + ( + "books.chart".into(), + ledger_store::access::Outcome::Ok, + true + ), + ] + ); + }); + } + + /// SharedStore INVARIANT 3: once the books are closing a call or a + /// read isn't run, and says so with the not-run code (a JSON-RPC + /// server error, which the agents' bridge sends again once the books + /// are back); each is recorded as refused. + #[test] + fn a_call_while_the_books_close_is_refused_as_not_run() { + rt().block_on(async { + let s = session("closing").await; + let (server, books) = logged("closing"); + server.store.close().await; + let chart = tool_call("books.chart", serde_json::json!({})); + let e = server + .call_tool(chart, context(&s.peer, Some(ALL))) + .await + .unwrap_err(); + assert_eq!(e.code.0, NOT_RUN_CLOSING_CODE); + assert_eq!(e.message, NOT_RUN_CLOSING); + // JSON-RPC 2.0 5.1: -32000 to -32099 is for the server's own + // errors. + assert!((-32099..=-32000).contains(&NOT_RUN_CLOSING_CODE)); + assert!((-32099..=-32000).contains(&NOT_RUN_STOPPED_CODE)); + let read = ReadResourceRequestParams::new("books://chart"); + let e = server + .read_resource(read, context(&s.peer, Some(ALL))) + .await + .unwrap_err(); + assert_eq!(e.code.0, NOT_RUN_CLOSING_CODE); + assert_eq!( + records(&books), + vec![ + ("books.chart".into(), refused("closing"), true), + ("books://chart".into(), refused("closing"), false), + ] + ); + }); + } + + /// Reading a resource is reading the books (access.rs): only a token + /// with books:read reads one, and every read is recorded with what + /// came of it. + #[test] + fn a_resource_is_read_only_with_books_read_and_each_read_is_recorded() { + use ledger_store::access::Outcome; + rt().block_on(async { + let s = session("resources").await; + let (server, books) = logged("resources"); + let read = |uri: &str| ReadResourceRequestParams::new(uri); + assert!( + server + .read_resource(read("books://chart"), context(&s.peer, Some(ALL))) + .await + .is_ok() + ); + let writer = [ledger_store::auth::Scope::Write]; + let e = server + .read_resource(read("books://chart"), context(&s.peer, Some(&writer[..]))) + .await + .unwrap_err(); + assert_eq!(e.message, crate::access::INSUFFICIENT_SCOPE); + let e = server + .read_resource(read("books://chart"), context(&s.peer, None)) + .await + .unwrap_err(); + assert_eq!(e.message, crate::access::UNAUTHORIZED); + // One that ran and failed is an error, not a refusal. + assert!( + server + .read_resource(read("books://nope"), context(&s.peer, Some(ALL))) + .await + .is_err() + ); + let outcomes: Vec<_> = records(&books).into_iter().map(|r| r.1).collect(); + assert_eq!( + outcomes, + vec![ + Outcome::Ok, + refused("insufficient_scope"), + refused("unauthorized"), + Outcome::Error, + ] + ); + }); + } + + /// SharedStore INVARIANTS 2-3 at the last moment: a call that passed + /// the checks, then found the books closing (or the server stopped) + /// as it went to run, isn't run either, and is answered and recorded + /// as one refused at the start would be. The settings are held so + /// the call waits between its checks and its run. + #[test] + fn a_call_the_books_close_or_stop_under_is_refused_as_not_run() { + let rt = rt(); + for (how, stops) in [("closes", false), ("stops", true)] { + let (server, books) = logged(&format!("late-{how}")); + // The settings, held on a thread of their own until released. + let (locked, is_locked) = std::sync::mpsc::channel(); + let (release, released) = std::sync::mpsc::channel::<()>(); + let settings = server.settings.clone(); + let holder = std::thread::spawn(move || { + let _held = settings.lock().unwrap(); + locked.send(()).unwrap(); + let _ = released.recv(); + }); + is_locked.recv().unwrap(); + let answered = rt.block_on(async { + let s = session(&format!("late-{how}")).await; + let (started, began) = tokio::sync::oneshot::channel(); + let calling = server.clone(); + let cx = context(&s.peer, Some(ALL)); + let call = tokio::spawn(async move { + let _ = started.send(()); + let chart = tool_call("books.chart", serde_json::json!({})); + calling.call_tool(chart, cx).await + }); + began.await.unwrap(); + // Past its checks (no I/O between): waiting for the settings. + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + if stops { + server.store.stop(); + } else { + server + .store + .closing + .store(true, std::sync::atomic::Ordering::SeqCst); + } + release.send(()).unwrap(); + call.await.unwrap() + }); + holder.join().unwrap(); + if stops { + assert_eq!(answer(answered).unwrap(), (true, STOPPED.to_string())); + // Refused after its arguments were taken: their digest too. + assert_eq!( + records(&books), + vec![("books.chart".into(), refused("stopped"), true)] + ); + } else { + let e = answered.unwrap_err(); + assert_eq!(e.code.0, NOT_RUN_CLOSING_CODE, "{e:?}"); + // Its arguments went with the request: no digest. + assert_eq!( + records(&books), + vec![("books.chart".into(), refused("closing"), false)] + ); + } + } + } + + /// access.rs INVARIANT 3: a record that can't be written stops the + /// books, and the answer says so; a call or read refused as not run + /// (closing) keeps its not-run code, so the bridge still sends it + /// again; anything else is an error in words. + #[test] + fn an_access_record_that_cant_be_written_stops_the_books_and_says_so() { + rt().block_on(async { + let s = session("unrecorded").await; + // A call refused as the books close. + let (server, _books) = unwritable("unrecorded-call"); + server.store.close().await; + let chart = tool_call("books.chart", serde_json::json!({})); + let e = server + .call_tool(chart, context(&s.peer, Some(ALL))) + .await + .unwrap_err(); + assert_eq!(e.code.0, NOT_RUN_CLOSING_CODE); + assert!(e.message.contains(crate::access::NOT_RECORDED), "{e:?}"); + assert!(server.store.stopped()); + // Any other answer refused unrun becomes the words alone. + let (server, _books) = unwritable("unrecorded-refusal"); + let call = crate::access::Call::new( + server.access.as_ref().unwrap(), + &context(&s.peer, Some(ALL)), + "tool", + "books.chart", + ); + let out = server + .refuse( + Some(&call), + crate::access::STOPPED, + Err(rmcp::ErrorData::internal_error("not run", None)), + ) + .await; + assert_eq!( + answer(out).unwrap(), + (true, crate::access::NOT_RECORDED.to_string()) + ); + assert!(server.store.stopped()); + // A read refused as the books close. + let (server, _books) = unwritable("unrecorded-closing-read"); + server.store.close().await; + let e = server + .read_resource( + ReadResourceRequestParams::new("books://chart"), + context(&s.peer, Some(ALL)), + ) + .await + .unwrap_err(); + assert_eq!(e.code.0, NOT_RUN_CLOSING_CODE); + assert!(e.message.contains(crate::access::NOT_RECORDED), "{e:?}"); + assert!(server.store.stopped()); + // A read that ran and failed. + let (server, _books) = unwritable("unrecorded-read"); + let e = server + .read_resource( + ReadResourceRequestParams::new("books://nope"), + context(&s.peer, Some(ALL)), + ) + .await + .unwrap_err(); + assert_eq!(e.code, rmcp::model::ErrorCode::INTERNAL_ERROR); + assert_eq!(e.message, crate::access::NOT_RECORDED); + assert!(server.store.stopped()); + }); + } + + /// A request whose blocking work the runtime never ran (it was + /// shutting down) is answered as not run, in the words a stopped + /// server uses, and stops nothing: only a request that panicked + /// stops the books (run_blocking). + #[test] + fn a_request_the_runtime_never_ran_is_answered_not_run_and_stops_nothing() { + let dead = rt(); + let handle = dead.handle().clone(); + drop(dead); + rt().block_on(async { + let s = session("never-ran").await; + let (server, _books) = logged("never-ran"); + let chart = tool_call("books.chart", serde_json::json!({})); + let mut call = std::pin::pin!(server.call_tool(chart, context(&s.peer, Some(ALL)))); + // Its blocking work goes to the runtime that shut down. + let out = std::future::poll_fn(|cx| { + let _in = handle.enter(); + call.as_mut().poll(cx) + }) + .await; + assert_eq!(answer(out).unwrap(), (true, STOPPED.to_string())); + assert!(!server.store.stopped()); + }); + } + + /// A tool list differs by caller (scopes.rs): a client on 2026-07-28 + /// or later is told to cache it for this caller alone, and not to keep + /// it; an older one, which knows neither hint, is told neither. + #[test] + fn a_tool_list_is_cached_only_for_its_caller() { + rt().block_on(async { + let s = session("hints").await; + let books = unit_dir("hints"); + let server = BooksServer::new(store(&books), ReceiptPolicy::default()); + for (version, hinted) in [ + (rmcp::model::ProtocolVersion::V_2026_07_28, true), + (rmcp::model::ProtocolVersion::V_2025_11_25, false), + ] { + let mut cx = context(&s.peer, None); + cx.meta.set_protocol_version(version.clone()); + let list = server.list_tools(None, cx).await.unwrap(); + assert!(!list.tools.is_empty()); + assert_eq!(list.ttl_ms, hinted.then_some(0), "{version:?}"); + assert_eq!( + list.cache_scope, + hinted.then_some(rmcp::model::CacheScope::Private), + "{version:?}" + ); + } + }); + } } diff --git a/crates/ledger-mcp/src/settings.rs b/crates/ledger-mcp/src/settings.rs index 339f217..df0267d 100644 --- a/crates/ledger-mcp/src/settings.rs +++ b/crates/ledger-mcp/src/settings.rs @@ -568,4 +568,19 @@ mod tests { live.sync(&books, "USD"); assert!(live.is_journaled()); } + + /// Books in the data root keep their own settings from the start + /// (INVARIANT 6): before they hold any, the settings in force say so + /// too, so no message sends anyone to a settings file; none is read. + #[test] + fn books_in_the_data_root_keep_their_own_settings_before_they_hold_any() { + let mut live = Live::own(); + assert!(live.current().journaled); + assert!(live.is_journaled()); + assert!(live.file_texts().is_none()); + live.sync(&ledger_core::settings::Settings::default(), "USD"); + assert!(live.current().journaled); + let s = live.status(); + assert!(s.journaled && s.books_toml.is_none(), "{s:?}"); + } } diff --git a/crates/ledger-mcp/src/tools/files.rs b/crates/ledger-mcp/src/tools/files.rs index 66ed5ae..88baf72 100644 --- a/crates/ledger-mcp/src/tools/files.rs +++ b/crates/ledger-mcp/src/tools/files.rs @@ -347,7 +347,7 @@ impl BooksServer { #[cfg(test)] mod tests { - use super::plain_file_name; + use super::{CALLER, plain_file_name, token_flag}; /// A name kept before names were checked is handed over cleaned, never /// refused (a W-9 sealed earlier, say). @@ -364,6 +364,10 @@ mod tests { let long = "x".repeat(400); let cut = plain_file_name(&long); assert!(cut.len() <= ledger_store::uploads::MAX_NAME_BYTES); + // Cut to fit, not dropped: as much of the name as the checks take, + // and a name that fits already kept whole. + assert_eq!(cut, "x".repeat(255)); + assert_eq!(plain_file_name(&"y".repeat(255)), "y".repeat(255)); for n in [ "W9 \u{2714}\u{FE0F}.pdf", "a/b\\c\n.pdf", @@ -377,4 +381,21 @@ mod tests { ); } } + + /// A command given to the agent names its token only when it isn't + /// the one the commands use by default ("agent"): the command runs as + /// the caller, never as another token. + #[test] + fn a_command_names_the_callers_token_unless_it_is_the_default() { + let flag_for = |caller: Option<(&str, &str)>| { + CALLER.with(|c| { + *c.borrow_mut() = caller.map(|(id, name)| (id.to_string(), name.to_string())) + }); + token_flag() + }; + assert_eq!(flag_for(None), ""); + assert_eq!(flag_for(Some(("tok_1", "agent"))), ""); + assert_eq!(flag_for(Some(("tok_2", "reader"))), " --token reader"); + assert_eq!(flag_for(None), ""); + } } diff --git a/crates/ledger-mcp/tests/spec_kept.rs b/crates/ledger-mcp/tests/spec_kept.rs index 55e6360..10ba0f1 100644 --- a/crates/ledger-mcp/tests/spec_kept.rs +++ b/crates/ledger-mcp/tests/spec_kept.rs @@ -221,6 +221,13 @@ fn a_settings_file_moves_into_the_books() { json!({"changes": [{"key": "company.cpa", "value": "Casey"}], "decided_by": "A"}), ); assert!(refused.contains("books.import_settings"), "{refused}"); + // Nor is there an export yet: the file is the copy. + let refused = m.call_err("books.export_settings", json!({})); + assert!( + refused.contains("still read their settings file") + && refused.contains("books.import_settings"), + "{refused}" + ); // The preview records nothing. let head = |m: &mut Mcp| m.call("books.start", json!({}))["head"]["seq"].clone(); let before = head(&mut m); diff --git a/crates/ledger-package/src/package.rs b/crates/ledger-package/src/package.rs index 091389a..925ebb7 100644 --- a/crates/ledger-package/src/package.rs +++ b/crates/ledger-package/src/package.rs @@ -410,8 +410,12 @@ pub fn build_package( let mut used: std::collections::HashSet = files.iter().map(|(n, _)| n.to_lowercase()).collect(); let mut unique = |name: String| -> String { - let (stem, ext) = match name.rsplit_once('.') { - Some((s, e)) if !s.ends_with('/') && !s.is_empty() => (s.to_string(), format!(".{e}")), + // The extension is the last segment's own: a folder with a dot + // ("a.b/notes") keeps its name, and a name that is all extension + // (".env") has none. + let cut = name.rfind('/').map_or(0, |i| i + 1); + let (stem, ext) = match name[cut..].rfind('.') { + Some(i) if i > 0 => (name[..cut + i].to_string(), name[cut + i..].to_string()), _ => (name.clone(), String::new()), }; let mut n = 1; @@ -424,7 +428,8 @@ pub fn build_package( }; if req.include_receipts { let clean = |s: &str| -> String { - s.chars() + let c: String = s + .chars() .map(|c| { if c.is_alphanumeric() || ".-_ ".contains(c) { c @@ -432,7 +437,14 @@ pub fn build_package( '_' } }) - .collect() + .collect(); + // A name of dots alone ("", ".", "..") would name no file, or + // climb out of its folder when the zip is unpacked. + if c.chars().all(|c| c == '.') { + "_".repeat(c.len().max(1)) + } else { + c + } }; let mut index = String::from("entry_id,date,description,amount,file,sha256\n"); for a in books.all_attachments().map_err(|e| e.to_string())? { diff --git a/crates/ledger-package/tests/package.rs b/crates/ledger-package/tests/package.rs index 6b4555f..f782126 100644 --- a/crates/ledger-package/tests/package.rs +++ b/crates/ledger-package/tests/package.rs @@ -797,3 +797,194 @@ fn a_single_members_readme_promises_no_totals_row() { let p = build_package(&b.store, &settings(&two, &cash), &request(false, None)).unwrap(); assert!(text(&p, "README.txt").contains("the last row totals")); } + +#[test] +fn a_dotfiles_twin_keeps_its_whole_name_and_its_folder() { + let dir = tempfile::tempdir().unwrap(); + let (mut s, _) = plain_books(dir.path()); + // A name that is all extension (".env"): its twin is ".env (2)", never + // " (2).env", a name made of nothing but a number. + for bytes in [b"one".as_slice(), b"two".as_slice()] { + s.file_document("legal", ".env", "text/plain", bytes, None) + .unwrap(); + } + let company = company(false); + let cash = cash(); + let p = build_package(&s, &settings(&company, &cash), &request(true, None)).unwrap(); + let docs: Vec<&str> = names(&p) + .into_iter() + .filter(|n| n.starts_with("documents/")) + .collect(); + assert_eq!(docs, ["documents/legal/.env", "documents/legal/.env (2)"]); + assert_eq!(text(&p, "documents/legal/.env"), "one"); + assert_eq!(text(&p, "documents/legal/.env (2)"), "two"); + assert_zip_is_the_files(&p); +} + +#[test] +fn a_twins_number_goes_on_its_own_name_and_no_name_climbs_out_of_its_folder() { + let dir = tempfile::tempdir().unwrap(); + let (mut s, _) = plain_books(dir.path()); + // A dot in the folder is not the file's extension: the second "notes" + // in "a.b" is "a.b/notes (2)", never "a (2).b/notes" in another + // folder. And a name of dots alone would climb out of its folder when + // the zip is unpacked: it becomes underscores. + for bytes in [b"one".as_slice(), b"two".as_slice()] { + s.file_document("a.b", "notes", "text/plain", bytes, None) + .unwrap(); + } + s.file_document("..", "..", "text/plain", b"three", None) + .unwrap(); + s.file_document("legal", ".", "text/plain", b"four", None) + .unwrap(); + let company = company(false); + let cash = cash(); + let p = build_package(&s, &settings(&company, &cash), &request(true, None)).unwrap(); + let docs: Vec<&str> = names(&p) + .into_iter() + .filter(|n| n.starts_with("documents/")) + .collect(); + assert_eq!( + docs, + [ + "documents/a.b/notes", + "documents/a.b/notes (2)", + "documents/__/__", + "documents/legal/_" + ] + ); + assert_eq!(text(&p, "documents/a.b/notes (2)"), "two"); + assert_eq!(text(&p, "documents/__/__"), "three"); + assert!( + names(&p).iter().all(|n| n + .split('/') + .all(|seg| seg != ".." && seg != "." && !seg.is_empty())), + "{:?}", + names(&p) + ); + assert_zip_is_the_files(&p); +} + +#[test] +fn the_years_first_and_last_days_receipts_are_in_and_the_days_beyond_are_not() { + let dir = tempfile::tempdir().unwrap(); + let (mut s, _) = plain_books(dir.path()); + // The fiscal year is 2026 (the default start, January 1) and as_of is + // its last day: the receipts on both edges go in, the ones a day + // outside stay out. + let mut on = Vec::new(); + for date in ["2025-12-31", "2026-01-01", "2026-12-31", "2027-01-01"] { + let e = post( + &mut s, + date, + "Rent", + &[ + ("6000", Side::Debit, "100.00"), + ("1000", Side::Credit, "100.00"), + ], + ); + s.attach_receipt(Some(e), "r.pdf", "application/pdf", date.as_bytes(), None) + .unwrap(); + on.push((date, e)); + } + let company = company(false); + let cash = cash(); + let p = build_package(&s, &settings(&company, &cash), &request(true, None)).unwrap(); + let receipts: Vec<&str> = names(&p) + .into_iter() + .filter(|n| n.starts_with("receipts/") && *n != "receipts/index.csv") + .collect(); + let first = format!("receipts/2026-01-01-entry-{}-r.pdf", on[1].1); + let last = format!("receipts/2026-12-31-entry-{}-r.pdf", on[2].1); + assert_eq!(receipts, [first.as_str(), last.as_str()]); + assert_eq!(p.receipt_count, 2); + assert_eq!(text(&p, &first), "2026-01-01"); + assert_eq!(text(&p, &last), "2026-12-31"); + assert_eq!(text(&p, "receipts/index.csv").lines().count(), 3); +} + +/// Contract labor paid three ways: from checking (cash), from a prepaid +/// asset that isn't cash, and by a member's advance (financing). +#[test] +fn payments_to_a_vendor_come_from_the_settings_accounts_alone() { + let dir = tempfile::tempdir().unwrap(); + let (mut s, _) = plain_books(dir.path()); + for (code, name, ty) in [ + ("1300", "Prepaid expenses", AccountType::Asset), + ("2100", "Member advances", AccountType::Liability), + ("6100", "Contract labor", AccountType::Expense), + ] { + s.add_account(code, name, ty, None, false).unwrap(); + } + let vendor = s.add_vendor("Acme Design").unwrap(); + s.set_vendor_1099(vendor, Some(Form1099::Nec), true) + .unwrap(); + for (from, amount) in [("1000", "1000.00"), ("1300", "400.00"), ("2100", "700.00")] { + let e = s.create_entry(d("2026-05-01"), "Acme design work").unwrap(); + s.add_line(e, id_of(&s, "6100"), Side::Debit, usd(amount)) + .unwrap(); + s.add_line(e, id_of(&s, from), Side::Credit, usd(amount)) + .unwrap(); + s.set_entry_vendor(e, Some(vendor)).unwrap(); + s.post_entry(e).unwrap(); + } + let company = company(false); + // A code the chart lacks is skipped. + let cash = vec!["1000".to_string(), "1999".to_string()]; + let financing = vec!["2100".to_string()]; + let st = Settings { + company: &company, + cash_accounts: &cash, + financing_accounts: &financing, + investing_accounts: &[], + }; + let ledger = s.ledger(); + assert_eq!( + ledger_package::payment_accounts(ledger, &st), + ledger_core::PaymentAccounts { + cash: vec![id_of(&s, "1000")], + financing: vec![id_of(&s, "2100")], + } + ); + // Checking and the advance count; the prepaid asset doesn't. + let w = vendor_1099_worksheet(ledger, &st, 2026, None, None).unwrap(); + assert_eq!(w.lines.len(), 1); + assert_eq!(w.lines[0].vendor_id, vendor); + assert_eq!(w.lines[0].paid, Decimal::from(1700)); +} + +#[test] +fn a_receipts_index_amount_is_the_entrys_debit_total_as_written() { + let dir = tempfile::tempdir().unwrap(); + let (mut s, _) = plain_books(dir.path()); + // A tool takes "100.000" as written (it is whole cents): the two sides + // are equal in value but not in spelling. The index prints the debit + // side's total, padded to cents, never the credit side's "100.000". + let e = post( + &mut s, + "2026-06-01", + "Rent, June", + &[ + ("6000", Side::Debit, "60"), + ("6000", Side::Debit, "40.0"), + ("1000", Side::Credit, "100.000"), + ], + ); + s.attach_receipt(Some(e), "june.pdf", "application/pdf", b"%PDF june", None) + .unwrap(); + let company = company(false); + let cash = cash(); + let p = build_package(&s, &settings(&company, &cash), &request(true, None)).unwrap(); + let index = text(&p, "receipts/index.csv"); + let row = index + .lines() + .find(|l| l.starts_with(&format!("{e},"))) + .unwrap(); + let fields: Vec<&str> = row.split(',').collect(); + assert_eq!( + fields[..4], + [e.to_string().as_str(), "2026-06-01", "\"Rent", " June\""] + ); + assert_eq!(fields[4], "100.00", "{row}"); + assert_eq!(fields[5], format!("receipts/2026-06-01-entry-{e}-june.pdf")); +} diff --git a/crates/ledger-package/tests/tins.rs b/crates/ledger-package/tests/tins.rs new file mode 100644 index 0000000..f9b6996 --- /dev/null +++ b/crates/ledger-package/tests/tins.rs @@ -0,0 +1,110 @@ +//! vault_tin: a worksheet's full TIN comes from the vault only when the +//! books' TIN facts and W-9 facts agree with it (the module's INVARIANTS +//! 1 and 2), checked against a real vault in a temp directory. + +use chrono::NaiveDate; +use ledger_core::{Currency, PartyId, Role, TinFacts, TinType, W9Facts}; +use ledger_package::vault_tin; +use ledger_store::Store; +use ledger_vault::{Tin, Vault}; +use std::path::Path; + +const EIN: &str = "12-3456789"; +/// A SHA-256 no vault record has. +const STALE: &str = "0000000000000000000000000000000000000000000000000000000000000000"; + +/// Books with one contractor, and a vault holding its EIN; the record's +/// hash comes back. +fn setup(dir: &Path) -> (Store, Result, PartyId, String) { + let mut s = Store::create(&dir.join("books-data"), Currency::USD).unwrap(); + let acme = s.add_party("Acme Design", &[Role::Contractor]).unwrap(); + let vault = Vault::open(&dir.join("vault"), &dir.join("vault.key"), true).unwrap(); + let hash = vault + .put_tin(acme, &Tin::new(EIN, ledger_vault::TinType::Ein).unwrap()) + .unwrap(); + (s, Ok(vault), acme, hash) +} + +fn tin_facts(tin_type: TinType, vault_record: Option<&str>) -> TinFacts { + TinFacts { + tin_type, + last4: "6789".into(), + vault_record: vault_record.map(String::from), + purged: None, + } +} + +fn w9(vault_record: Option<&str>) -> W9Facts { + W9Facts { + received: NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(), + entity_type: "C corporation".into(), + exempt_payee_code: None, + fatca_code: None, + tin_last4: "6789".into(), + kept_at: "wanderbooks vault".into(), + vault_record: vault_record.map(String::from), + } +} + +#[test] +fn a_tin_of_another_type_with_the_same_last_four_is_refused() { + let dir = tempfile::tempdir().unwrap(); + let (mut s, vault, acme, _) = setup(dir.path()); + // The books say an SSN ending 6789; the vault holds an EIN ending 6789. + s.set_party_tin(acme, Some(tin_facts(TinType::Ssn, None))) + .unwrap(); + let err = vault_tin(s.ledger(), &vault, acme).unwrap_err(); + assert_eq!( + err, + "the vault's TIN (ein, ending 6789) isn't the one the books record (ssn, ending 6789): record the right one with books.set_tin" + ); + // The right type: the TIN. + s.set_party_tin(acme, Some(tin_facts(TinType::Ein, None))) + .unwrap(); + assert_eq!( + vault_tin(s.ledger(), &vault, acme).unwrap().digits(), + "123456789" + ); +} + +#[test] +fn with_only_w9_facts_their_vault_record_must_be_the_records_hash() { + let dir = tempfile::tempdir().unwrap(); + let (mut s, vault, acme, hash) = setup(dir.path()); + // The W-9 facts name the record: the TIN. + s.set_party_w9(acme, Some(w9(Some(&hash)))).unwrap(); + assert_eq!( + vault_tin(s.ledger(), &vault, acme).unwrap().digits(), + "123456789" + ); + // They name no record: nothing to check it against, the TIN. + s.set_party_w9(acme, Some(w9(None))).unwrap(); + assert_eq!( + vault_tin(s.ledger(), &vault, acme).unwrap().digits(), + "123456789" + ); + // They name another record: refused. + s.set_party_w9(acme, Some(w9(Some(STALE)))).unwrap(); + assert_eq!( + vault_tin(s.ledger(), &vault, acme).unwrap_err(), + "the vault record isn't the one the books recorded: check the W-9 and record it again with books.set_w9 tin" + ); +} + +#[test] +fn with_tin_facts_the_w9_facts_record_is_not_the_one_checked() { + let dir = tempfile::tempdir().unwrap(); + let (mut s, vault, acme, hash) = setup(dir.path()); + // The TIN facts name the record; the W-9 facts name an older one, or + // none: the TIN facts' record is the one that counts (INVARIANT 2). + s.set_party_tin(acme, Some(tin_facts(TinType::Ein, Some(&hash)))) + .unwrap(); + for record in [Some(STALE), None] { + s.set_party_w9(acme, Some(w9(record))).unwrap(); + assert_eq!( + vault_tin(s.ledger(), &vault, acme).unwrap().digits(), + "123456789", + "{record:?}" + ); + } +} diff --git a/crates/ledger-store/src/access.rs b/crates/ledger-store/src/access.rs index b91de43..1e83f1a 100644 --- a/crates/ledger-store/src/access.rs +++ b/crates/ledger-store/src/access.rs @@ -404,6 +404,61 @@ mod tests { ); } + #[test] + fn a_value_under_a_key_naming_a_tin_is_masked_whatever_it_looks_like() { + // Even a value that doesn't look like a TIN (a last four, a typo): + // the key says what it is. + for key in [ + "tin", + "TIN", + "payee_tin", + "Member_TIN", + "ssn", + "ein", + "itin", + ] { + assert_eq!( + digest(&serde_json::json!({ key: "6789" })), + digest(&serde_json::json!({ key: "1234" })), + "{key}" + ); + assert_eq!( + digest(&serde_json::json!({ key: {"last4": 6789} })), + digest(&serde_json::json!({ key: ["anything"] })), + "{key}" + ); + // A TIN not given isn't one: null stays null. + assert_ne!( + digest(&serde_json::json!({ key: null })), + digest(&serde_json::json!({ key: "6789" })), + "{key}" + ); + } + // Only those keys: one that merely holds the letters counts. + assert_ne!( + digest(&serde_json::json!({"tint": "6789"})), + digest(&serde_json::json!({"tint": "1234"})) + ); + } + + #[test] + fn a_files_bytes_are_hashed_as_they_are_even_where_they_spell_digits() { + // Base64 can hold a run of nine digits; it is still a file's bytes, + // not a TIN, and two files differing there differ. + for key in FILE_BYTES { + assert_ne!( + digest(&serde_json::json!({ key: "QUJD123456789QUJD" })), + digest(&serde_json::json!({ key: "QUJD987814321QUJD" })), + "{key}" + ); + } + // The same text anywhere else is masked. + assert_eq!( + digest(&serde_json::json!({"memo": "QUJD123456789QUJD"})), + digest(&serde_json::json!({"memo": "QUJD987814321QUJD"})) + ); + } + #[test] fn a_tin_at_the_cut_is_masked_whole() { // The TIN straddles the 64-character limit. diff --git a/crates/ledger-store/src/auth.rs b/crates/ledger-store/src/auth.rs index 4b7324b..ea8ff27 100644 --- a/crates/ledger-store/src/auth.rs +++ b/crates/ledger-store/src/auth.rs @@ -579,6 +579,40 @@ mod tests { assert_eq!(e, AuthError::Newer(1)); } + #[test] + fn a_replacement_names_the_live_token_of_its_own_book_and_name() { + let replacing = |old: &str, book: &str, name: &str| { + let mut r = created("tok_2", name); + r["book"] = book.into(); + r["replaces"] = old.into(); + r + }; + // The right one: it takes the old one's place. + let p = open_with(&[created("tok_1", "a"), replacing("tok_1", "acme", "a")]).unwrap(); + let live: Vec<&str> = p.tokens().iter().map(|t| t.id.as_str()).collect(); + assert_eq!(live, ["tok_2"]); + // Any one of id, book or name wrong: a log that contradicts itself. + for wrong in [ + replacing("tok_9", "acme", "a"), + replacing("tok_1", "other", "a"), + replacing("tok_1", "acme", "b"), + ] { + let e = open_with(&[created("tok_1", "a"), wrong.clone()]).unwrap_err(); + assert!(matches!(e, AuthError::Inconsistent(2, _)), "{wrong}: {e}"); + } + } + + #[test] + fn a_revocation_ends_the_token_it_names_and_no_other() { + let revoked = |id: &str| serde_json::json!({"event": "token.revoked", "id": id}); + for (gone, left) in [("tok_1", "tok_2"), ("tok_2", "tok_1")] { + let p = + open_with(&[created("tok_1", "a"), created("tok_2", "b"), revoked(gone)]).unwrap(); + let live: Vec<&str> = p.tokens().iter().map(|t| t.id.as_str()).collect(); + assert_eq!(live, [left], "{gone} revoked"); + } + } + #[test] fn a_changed_record_is_found() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/ledger-store/src/chain.rs b/crates/ledger-store/src/chain.rs index 5a43051..e864d99 100644 --- a/crates/ledger-store/src/chain.rs +++ b/crates/ledger-store/src/chain.rs @@ -441,6 +441,64 @@ mod tests { } } + #[test] + fn a_log_the_disk_wont_read_is_refused_not_taken_for_empty() { + // A folder where the log should be: reading it fails, and an empty + // log is never made of that. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("log.jsonl"); + std::fs::create_dir(&path).unwrap(); + assert!(matches!(read(&path, G), Err(ChainError::Io(_)))); + assert!(matches!(ChainLog::open(&path, G), Err(ChainError::Io(_)))); + } + + #[test] + fn a_log_shows_its_head_but_never_its_path() { + let dir = tempfile::tempdir().unwrap(); + let path = three(dir.path()); + let (log, _) = ChainLog::open(&path, G).unwrap(); + let shown = format!("{log:?}"); + assert!(shown.starts_with("ChainLog"), "{shown}"); + assert!(shown.contains("seq: 3"), "{shown}"); + assert!(shown.contains(&log.head().hash), "{shown}"); + assert!(shown.contains("broken: false"), "{shown}"); + assert!( + !shown.contains(&dir.path().display().to_string()), + "{shown}" + ); + } + + #[cfg(unix)] + #[test] + fn only_a_new_log_syncs_its_folder_and_only_others_bits_are_taken_away() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let path = three(dir.path()); + // The owner's own bits are the owner's: a log 0700 stays 0700. + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap(); + drop(ChainLog::open(&path, G).unwrap()); + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o700); + // A folder that can be entered but not listed: a log already in it + // opens (its folder's entry for it was synced when it was made); + // a new one can't be made there, as its entry can't be synced. + let shut = dir.path().join("shut"); + std::fs::create_dir(&shut).unwrap(); + let (mut log, _) = ChainLog::open(&shut.join("old.jsonl"), G).unwrap(); + log.append(fields(1)).unwrap(); + drop(log); + std::fs::set_permissions(&shut, std::fs::Permissions::from_mode(0o300)).unwrap(); + if File::open(&shut).is_err() { + let (_, records) = ChainLog::open(&shut.join("old.jsonl"), G).unwrap(); + assert_eq!(records.len(), 1); + assert_eq!( + ChainLog::open(&shut.join("new.jsonl"), G).unwrap_err(), + ChainError::Io(std::io::ErrorKind::PermissionDenied) + ); + } + std::fs::set_permissions(&shut, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + #[test] fn an_unfinished_last_line_is_cut_away_on_open() { let dir = tempfile::tempdir().unwrap(); @@ -484,6 +542,23 @@ mod tests { ] { assert!(!e.to_string().contains('/'), "{e}"); } + // Each says what went wrong, and where in the log. + assert_eq!( + ChainError::Damaged { + line: 3, + why: "its number is out of order".into(), + } + .to_string(), + "record 3 is damaged: its number is out of order" + ); + assert_eq!( + ChainError::Io(std::io::ErrorKind::PermissionDenied).to_string(), + "the disk refused: permission denied" + ); + assert_eq!( + ChainError::Broken.to_string(), + "an earlier record couldn't be written" + ); } #[cfg(target_os = "linux")] diff --git a/crates/ledger-store/src/exports.rs b/crates/ledger-store/src/exports.rs index 4adc678..137c10d 100644 --- a/crates/ledger-store/src/exports.rs +++ b/crates/ledger-store/src/exports.rs @@ -256,6 +256,85 @@ mod tests { const NOW: i64 = 1_800_000_000; + #[test] + fn an_export_is_kept_fifteen_minutes() { + // What its refusal tells the human: kept 15 minutes, then gone. + let dir = tempfile::tempdir().unwrap(); + let x = Exports::open(dir.path(), NOW).unwrap(); + let e = x.put("t", "a.csv", "text/csv", b"x", NOW).unwrap(); + assert_eq!(e.expires, NOW + 15 * 60); + assert!(x.get(&e.id, "t", NOW + 15 * 60 - 1).is_ok()); + let lapsed = x.get(&e.id, "t", NOW + 15 * 60).unwrap_err(); + assert!(lapsed.to_string().contains("kept 15 minutes"), "{lapsed}"); + } + + #[test] + fn an_id_is_ex_and_thirty_two_letters_a_to_p_and_nothing_else() { + assert!(is_id(&format!("ex_{}", "a".repeat(32)))); + assert!(is_id(&format!("ex_{}", "p".repeat(32)))); + for bad in [ + String::new(), + "ex_".into(), + format!("ex_{}", "a".repeat(31)), + format!("ex_{}", "a".repeat(33)), + format!("up_{}", "a".repeat(32)), + format!("exx{}", "a".repeat(32)), + format!("ex_{}", "q".repeat(32)), + format!("ex_{}0", "a".repeat(31)), + format!("ex_{}.", "a".repeat(31)), + ] { + assert!(!is_id(&bad), "{bad}"); + } + } + + #[test] + fn a_fetch_never_reaches_past_the_exports_folder() { + // A whole export kept beside the books, not among their exports: + // no id names it. + let dir = tempfile::tempdir().unwrap(); + let x = Exports::open(dir.path(), NOW).unwrap(); + let bytes = b"not an export".to_vec(); + for id in ["../outside", &format!("up_{}", "a".repeat(32))] { + let at = dir.path().join(EXPORTS_DIR).join(id); + let record = Export { + id: id.into(), + token: "t".into(), + file_name: "a.csv".into(), + mime: "text/csv".into(), + size: bytes.len() as u64, + sha256: sha256_hex(&bytes), + expires: NOW + EXPORT_TTL, + }; + std::fs::write( + at.with_extension("json"), + serde_json::to_vec(&record).unwrap(), + ) + .unwrap(); + std::fs::write(at.with_extension("bin"), &bytes).unwrap(); + assert!( + matches!(x.get(id, "t", NOW).unwrap_err(), ExportError::Unknown(_)), + "{id}" + ); + } + // And a refusal quotes no more than 40 characters of what it got. + let long = format!("../{}", "x".repeat(100)); + let ExportError::Unknown(quoted) = x.get(&long, "t", NOW).unwrap_err() else { + panic!("an unknown export") + }; + assert_eq!(quoted.chars().count(), 40); + } + + #[test] + fn an_export_counts_against_its_token_until_the_moment_it_lapses() { + let dir = tempfile::tempdir().unwrap(); + let x = Exports::open(dir.path(), NOW).unwrap(); + let e = x.put("t", "a.csv", "text/csv", b"x", NOW).unwrap(); + assert_eq!(x.live_for("t", e.expires - 1), 1); + // Lapsed at its expiry, as a fetch then finds (not yet swept). + assert_eq!(x.live_for("t", e.expires), 0); + assert_eq!(x.live_for("u", NOW), 0); + } + #[test] fn an_export_is_fetched_by_its_token_until_it_lapses() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/ledger-store/src/lib.rs b/crates/ledger-store/src/lib.rs index bb95dd2..f9b5b01 100644 --- a/crates/ledger-store/src/lib.rs +++ b/crates/ledger-store/src/lib.rs @@ -31,6 +31,8 @@ pub mod meta; pub mod ops; pub mod passphrase; pub mod reconcilable; +#[cfg(test)] +mod refuse; pub mod root; pub mod saving; pub mod schema; diff --git a/crates/ledger-store/src/passphrase.rs b/crates/ledger-store/src/passphrase.rs index 4c7d3d6..3bb2a3d 100644 --- a/crates/ledger-store/src/passphrase.rs +++ b/crates/ledger-store/src/passphrase.rs @@ -288,6 +288,72 @@ mod tests { assert!(verify(dir.path(), FIRST).unwrap()); } + /// A record of `FIRST` hashed with the plan's parameters but this salt + /// and output length, as a hand could write it. + fn record_with(dir: &Path, salt: &[u8], out: usize) { + let hasher = Argon2::new( + Algorithm::Argon2id, + Version::V0x13, + Params::new(M_COST, T_COST, P_COST, Some(out)).unwrap(), + ); + let owner = Owner { + argon2id: hasher + .hash_password_with_salt(FIRST.as_bytes(), salt) + .unwrap() + .to_string(), + set: "2026-10-19T00:00:00Z".into(), + }; + std::fs::write(dir.join(OWNER_FILE), serde_json::to_string(&owner).unwrap()).unwrap(); + } + + #[test] + fn a_salt_shorter_than_sixteen_bytes_or_an_output_not_thirty_two_is_refused() { + let dir = tempfile::tempdir().unwrap(); + // The plan's own: a salt of 16 bytes or more, an output of 32. + for salt in [16, 17, 32, 48] { + record_with(dir.path(), &vec![7u8; salt], 32); + assert_eq!(verify(dir.path(), FIRST), Ok(true), "a {salt}-byte salt"); + } + // Each right but for the salt's length, or the output's. + for (salt, out) in [(8, 32), (15, 32), (16, 16), (16, 64)] { + record_with(dir.path(), &vec![7u8; salt], out); + assert!( + matches!(verify(dir.path(), FIRST), Err(PassError::Record(_))), + "a {salt}-byte salt, a {out}-byte output" + ); + } + } + + #[test] + fn a_record_the_disk_wont_read_is_never_taken_for_none() { + // A folder where the record should be: no passphrase can be set + // as if none were, and the refusal is the disk's own. + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join(OWNER_FILE)).unwrap(); + assert!(matches!(is_set(dir.path()), Err(PassError::Io(_)))); + assert!(matches!(verify(dir.path(), FIRST), Err(PassError::Io(_)))); + assert!(matches!( + set(dir.path(), FIRST, None), + Err(PassError::Io(_)) + )); + } + + #[cfg(target_os = "linux")] + #[test] + fn a_leftover_that_cant_be_cleared_stops_a_change_saying_why() { + // A folder (with something in it) left where the record is written + // aside: removing it fails, and that failure is what is reported. + let dir = tempfile::tempdir().unwrap(); + let aside = dir.path().join(".owner.json.new"); + std::fs::create_dir(&aside).unwrap(); + std::fs::write(aside.join("x"), "x").unwrap(); + assert_eq!( + set(dir.path(), FIRST, None), + Err(PassError::Io(std::io::ErrorKind::IsADirectory)) + ); + assert!(!is_set(dir.path()).unwrap()); + } + #[test] fn errors_name_no_path_and_repeat_no_passphrase() { for e in [ diff --git a/crates/ledger-store/src/refuse.rs b/crates/ledger-store/src/refuse.rs new file mode 100644 index 0000000..3f5bf5f --- /dev/null +++ b/crates/ledger-store/src/refuse.rs @@ -0,0 +1,93 @@ +//! Tests only: system calls refused, as a disk or a file system can +//! refuse them, in one thread of the test's own. A seccomp filter makes +//! each named call fail at once with the error chosen, before the kernel +//! looks at any path, so a refusal no real folder can be made to give +//! (a lookup that fails where making the folder would not; a link +//! refused where the name is already taken) is given every time. The +//! filter holds the calling thread only and ends with it: the rest of +//! the test process is untouched. +#![cfg(target_os = "linux")] + +/// Run `f` in a thread of its own in which each call in `calls` fails +/// with `errno`, and give back what it returns; None where the system +/// won't put the filter on (the test then has nothing to show). +pub(crate) fn refusing( + calls: &[libc::c_long], + errno: i32, + f: impl FnOnce() -> T + Send, +) -> Option { + std::thread::scope(|s| { + s.spawn(|| { + // Looked up once before the filter, so std knows statx is + // there and never mistakes a refused one for a missing one. + let _ = std::fs::symlink_metadata("/"); + filter(calls, errno).then(f) + }) + .join() + .unwrap() + }) +} + +/// Put the filter on this thread: the call's number checked against +/// each of `calls`, a match refused with `errno`, anything else let be. +fn filter(calls: &[libc::c_long], errno: i32) -> bool { + use libc::{ + BPF_ABS, BPF_JEQ, BPF_JMP, BPF_K, BPF_LD, BPF_RET, BPF_W, SECCOMP_RET_ALLOW, + SECCOMP_RET_ERRNO, sock_filter, + }; + let stmt = |code: u32, k: u32| sock_filter { + code: code as u16, + jt: 0, + jf: 0, + k, + }; + let n = calls.len(); + let mut prog = vec![stmt( + BPF_LD | BPF_W | BPF_ABS, + std::mem::offset_of!(libc::seccomp_data, nr) as u32, + )]; + for (i, call) in calls.iter().enumerate() { + prog.push(sock_filter { + code: (BPF_JMP | BPF_JEQ | BPF_K) as u16, + // To the refusal, past the other checks and the allow. + jt: u8::try_from(n - i).unwrap(), + jf: 0, + k: u32::try_from(*call).unwrap(), + }); + } + prog.push(stmt(BPF_RET | BPF_K, SECCOMP_RET_ALLOW)); + prog.push(stmt( + BPF_RET | BPF_K, + SECCOMP_RET_ERRNO | u32::try_from(errno).unwrap(), + )); + let fprog = libc::sock_fprog { + len: u16::try_from(prog.len()).unwrap(), + filter: prog.as_mut_ptr(), + }; + // SAFETY: prctl with a filter program that outlives the call (the + // kernel copies it); without TSYNC it binds this thread alone. + unsafe { + libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) == 0 + && libc::prctl( + libc::PR_SET_SECCOMP, + libc::SECCOMP_MODE_FILTER, + &fprog as *const libc::sock_fprog, + ) == 0 + } +} + +/// The calls that look a name up without following it (std's +/// `symlink_metadata`): statx, and what std and libc fall back to. +pub(crate) const LOOKUPS: &[libc::c_long] = &[ + libc::SYS_statx, + libc::SYS_newfstatat, + #[cfg(target_arch = "x86_64")] + libc::SYS_lstat, +]; + +/// The calls that make a hard link (std's `hard_link`). +pub(crate) const LINKS: &[libc::c_long] = &[ + libc::SYS_linkat, + #[cfg(target_arch = "x86_64")] + libc::SYS_link, +]; diff --git a/crates/ledger-store/src/root.rs b/crates/ledger-store/src/root.rs index 568a5cc..3f71aa2 100644 --- a/crates/ledger-store/src/root.rs +++ b/crates/ledger-store/src/root.rs @@ -1711,4 +1711,29 @@ mod unit { let (_, login) = unix_account().unwrap(); assert!(!login.is_empty()); } + + #[cfg(target_os = "linux")] + #[test] + fn a_folder_the_disk_wont_look_up_is_never_made_over() { + // The disk can't say what is under the name (an I/O error, where + // making the folder would go through): refused with the disk's + // own error, and no folder made over whatever is there. + let dir = tempfile::tempdir().unwrap(); + let book = dir.path(); + let Some(got) = crate::refuse::refusing(crate::refuse::LOOKUPS, libc::EIO, || { + private_book_folder(book, "keys") + }) else { + eprintln!("skipped: no seccomp here"); + return; + }; + let Err(RootError::Io(why, e)) = got else { + panic!("refused as an I/O error: {got:?}") + }; + assert_eq!(e.raw_os_error(), Some(libc::EIO)); + assert!(!why.contains(&book.display().to_string()), "{why}"); + assert!(!book.join("keys").exists()); + // Looked up as it should be: made, private. + let keys = private_book_folder(book, "keys").unwrap(); + assert_eq!(keys, book.join("keys")); + } } diff --git a/crates/ledger-store/src/saving.rs b/crates/ledger-store/src/saving.rs index b3becfb..04f09a7 100644 --- a/crates/ledger-store/src/saving.rs +++ b/crates/ledger-store/src/saving.rs @@ -124,6 +124,170 @@ pub fn save_new( mod tests { use super::*; + #[test] + fn a_hidden_name_keeps_its_dot_when_numbered() { + // `.env` is a stem, not an extension: `.env (1)`, never ` (1).env`. + let dir = tempfile::tempdir().unwrap(); + let d = dir.path(); + assert_eq!( + save_new(d, ".env", "attachment-7", b"a").unwrap(), + d.join(".env") + ); + assert_eq!( + save_new(d, ".env", "attachment-7", b"b").unwrap(), + d.join(".env (1)") + ); + assert_eq!( + save_new(d, "r.tar.gz", "attachment-7", b"c").unwrap(), + d.join("r.tar.gz") + ); + assert_eq!( + save_new(d, "r.tar.gz", "attachment-7", b"d").unwrap(), + d.join("r.tar (1).gz") + ); + } + + #[test] + fn when_no_name_is_taken_the_folders_own_refusal_is_said() { + // Both names too long for the folder: its refusal, not "no free + // name", and nothing left behind. + let dir = tempfile::tempdir().unwrap(); + let d = dir.path(); + let e = save_new(d, &"x".repeat(300), &"y".repeat(300), b"ours").unwrap_err(); + assert!(!e.contains("no free name"), "{e}"); + assert!( + e.ends_with(&std::io::ErrorKind::InvalidFilename.to_string()), + "{e}" + ); + assert_eq!(std::fs::read_dir(d).unwrap().count(), 0); + } + + #[cfg(unix)] + #[test] + fn when_a_folder_refuses_the_copy_written_in_place_its_refusal_is_said() { + // Both names in a folder that can't be written: the link is + // refused (permission denied, so the copy is tried in place), and + // so is the file in place. That refusal is said, not "no free + // name", and the next name is never tried for it. + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let d = dir.path(); + let ro = d.join("ro"); + std::fs::create_dir(&ro).unwrap(); + std::fs::set_permissions(&ro, std::fs::Permissions::from_mode(0o500)).unwrap(); + // (Skipped where nothing is refused, as for root.) + if std::fs::write(ro.join("probe"), b"").is_err() { + let e = save_new(d, "ro/r.pdf", "ro/attachment-7", b"ours").unwrap_err(); + assert!(!e.contains("no free name"), "{e}"); + assert!( + e.ends_with(&std::io::ErrorKind::PermissionDenied.to_string()), + "{e}" + ); + // A name of its own that the folder takes: that one. + assert_eq!( + save_new(d, "ro/r.pdf", "attachment-7", b"ours").unwrap(), + d.join("attachment-7") + ); + } + std::fs::set_permissions(&ro, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + + #[cfg(target_os = "linux")] + #[test] + fn a_name_refused_for_another_reason_than_links_moves_on_never_written_in_place() { + // A name reaching onto another file system: the link is refused + // (it crosses devices), and the copy is never written there in + // place as if the folder took no links; the fallback is used. + use std::os::unix::fs::MetadataExt; + let dir = tempfile::tempdir().unwrap(); + let d = dir.path(); + let Ok(other) = tempfile::tempdir_in("/dev/shm") else { + return; + }; + let dev = |p: &std::path::Path| std::fs::metadata(p).unwrap().dev(); + if dev(d) == dev(other.path()) { + return; + } + std::os::unix::fs::symlink(other.path(), d.join("elsewhere")).unwrap(); + assert_eq!( + save_new(d, "elsewhere/r.pdf", "attachment-7", b"ours").unwrap(), + d.join("attachment-7") + ); + assert_eq!(std::fs::read_dir(other.path()).unwrap().count(), 0); + } + + #[cfg(target_os = "linux")] + #[test] + fn a_folder_without_links_gets_the_copy_written_in_place_never_over_a_file() { + // Links refused as FAT refuses them (not supported, or not + // permitted), and before the folder looks at the name: a name + // already taken is then found only when the copy is written in + // place, and the next name is tried, never the fallback, never + // over the file. + for errno in [libc::EOPNOTSUPP, libc::EPERM] { + let dir = tempfile::tempdir().unwrap(); + let d = dir.path(); + std::fs::write(d.join("theirs.pdf"), b"theirs").unwrap(); + let Some(saved) = crate::refuse::refusing(crate::refuse::LINKS, errno, || { + [ + save_new(d, "r.pdf", "attachment-7", b"ours"), + save_new(d, "r.pdf", "attachment-7", b"again"), + save_new(d, "theirs.pdf", "attachment-7", b"mine"), + ] + }) else { + eprintln!("skipped: no seccomp here"); + return; + }; + assert_eq!( + saved, + [ + Ok(d.join("r.pdf")), + Ok(d.join("r (1).pdf")), + Ok(d.join("theirs (1).pdf")), + ], + "errno {errno}" + ); + assert_eq!(std::fs::read(d.join("r.pdf")).unwrap(), b"ours"); + assert_eq!(std::fs::read(d.join("r (1).pdf")).unwrap(), b"again"); + assert_eq!(std::fs::read(d.join("theirs.pdf")).unwrap(), b"theirs"); + assert_eq!(std::fs::read(d.join("theirs (1).pdf")).unwrap(), b"mine"); + // Nothing aside is left. + let mut names: Vec = std::fs::read_dir(d) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + names.sort(); + assert_eq!( + names, + ["r (1).pdf", "r.pdf", "theirs (1).pdf", "theirs.pdf"], + "errno {errno}" + ); + } + } + + #[cfg(target_os = "linux")] + #[test] + fn a_link_refused_for_any_other_reason_is_never_written_in_place() { + // Every link refused as crossing devices: no copy is written in + // place as if the folder took no links; the folder's refusal is + // said, and nothing is left behind. + let dir = tempfile::tempdir().unwrap(); + let d = dir.path(); + let Some(saved) = crate::refuse::refusing(crate::refuse::LINKS, libc::EXDEV, || { + save_new(d, "r.pdf", "attachment-7", b"ours") + }) else { + eprintln!("skipped: no seccomp here"); + return; + }; + let e = saved.unwrap_err(); + assert!(!e.contains("no free name"), "{e}"); + assert!( + e.ends_with(&std::io::ErrorKind::CrossesDevices.to_string()), + "{e}" + ); + assert_eq!(std::fs::read_dir(d).unwrap().count(), 0); + } + #[test] fn a_saved_copy_is_new_and_whole_beside_what_is_there() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/ledger-store/src/uploads.rs b/crates/ledger-store/src/uploads.rs index c90509e..9528f3d 100644 --- a/crates/ledger-store/src/uploads.rs +++ b/crates/ledger-store/src/uploads.rs @@ -907,6 +907,135 @@ mod tests { assert!(!folder.join(format!("{}.part", g.id)).exists()); } + #[test] + fn bytes_that_finish_after_anothers_were_kept_or_held_are_not_kept() { + // A send is under way when the books are opened again (its `.part` + // dropped as a stopped server's half send) and another send of the + // same bytes finishes first: those are kept, or held by a call that + // took them. The first send, finishing, finds that and keeps + // nothing. + for hold in [false, true] { + let dir = tempfile::tempdir().unwrap(); + let u = Uploads::open(dir.path(), NOW).unwrap(); + let bytes = b"%PDF-1.4 twice".to_vec(); + let g = grant_for(&u, "tok_a", &bytes); + let held = std::cell::RefCell::new(None); + let mut body = Forgetting { + bytes: &bytes, + then: Some(Box::new(|| { + let again = Uploads::open(dir.path(), NOW).unwrap(); + again.receive(&g.id, "tok_a", &mut &bytes[..], NOW).unwrap(); + if hold { + *held.borrow_mut() = Some(again.take(&g.id, "tok_a", NOW).unwrap()); + } + })), + }; + assert_eq!( + u.receive(&g.id, "tok_a", &mut body, NOW).unwrap_err(), + UploadError::AlreadySent, + "held: {hold}" + ); + drop(body); + // The other send's bytes are what the grant has. + let r = held + .into_inner() + .unwrap_or_else(|| u.take(&g.id, "tok_a", NOW).unwrap()); + assert_eq!(r.bytes, bytes); + assert!( + !dir.path() + .join(UPLOADS_DIR) + .join(format!("{}.part", g.id)) + .exists() + ); + } + } + + #[test] + fn a_grant_lasts_fifteen_minutes() { + // Plan 4.7: "valid 15 minutes". + let dir = tempfile::tempdir().unwrap(); + let u = Uploads::open(dir.path(), NOW).unwrap(); + let bytes = b"%PDF-1.4 in time".to_vec(); + let g = grant_for(&u, "tok_a", &bytes); + assert_eq!(g.expires, NOW + 15 * 60); + u.receive(&g.id, "tok_a", &mut &bytes[..], NOW + 15 * 60 - 1) + .unwrap(); + assert!(matches!( + u.take(&g.id, "tok_a", NOW + 15 * 60).unwrap_err(), + UploadError::Unknown(_) + )); + } + + #[test] + fn a_grant_counts_against_its_token_until_the_moment_it_lapses() { + let dir = tempfile::tempdir().unwrap(); + let u = Uploads::open(dir.path(), NOW).unwrap(); + let g = grant_for(&u, "tok_a", b"x"); + assert_eq!(u.live_for("tok_a", g.expires - 1), 1); + // Lapsed at its expiry, as a send then finds (not yet swept). + assert_eq!(u.live_for("tok_a", g.expires), 0); + assert_eq!(u.live_for("tok_b", NOW), 0); + } + + /// A body that must never be read. + struct Unread; + + impl std::io::Read for Unread { + fn read(&mut self, _: &mut [u8]) -> std::io::Result { + panic!("bytes already sent are never read again"); + } + } + + #[test] + fn bytes_sent_or_held_are_refused_before_anything_more_is_read() { + let dir = tempfile::tempdir().unwrap(); + let u = Uploads::open(dir.path(), NOW).unwrap(); + let bytes = b"%PDF-1.4 once".to_vec(); + let g = grant_for(&u, "tok_a", &bytes); + u.receive(&g.id, "tok_a", &mut &bytes[..], NOW).unwrap(); + // Kept: refused at once, whatever comes. + assert_eq!( + u.receive(&g.id, "tok_a", &mut Unread, NOW).unwrap_err(), + UploadError::AlreadySent + ); + // Held by a call: the same. + let held = u.take(&g.id, "tok_a", NOW).unwrap(); + assert_eq!( + u.receive(&g.id, "tok_a", &mut Unread, NOW).unwrap_err(), + UploadError::AlreadySent + ); + assert!( + !dir.path() + .join(UPLOADS_DIR) + .join(format!("{}.part", g.id)) + .exists() + ); + drop(held); + } + + #[cfg(unix)] + #[test] + fn a_send_the_disk_refuses_says_so_not_that_another_is_under_way() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let u = Uploads::open(dir.path(), NOW).unwrap(); + let bytes = b"%PDF-1.4 refused".to_vec(); + let g = grant_for(&u, "tok_a", &bytes); + let folder = dir.path().join(UPLOADS_DIR); + std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o500)).unwrap(); + // (Skipped where nothing is refused, as for root.) + if std::fs::write(folder.join("probe"), b"").is_err() { + assert_eq!( + u.receive(&g.id, "tok_a", &mut &bytes[..], NOW).unwrap_err(), + UploadError::Io(std::io::ErrorKind::PermissionDenied) + ); + } + std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o700)).unwrap(); + // The grant still stands. + let _ = std::fs::remove_file(folder.join("probe")); + u.receive(&g.id, "tok_a", &mut &bytes[..], NOW).unwrap(); + } + #[test] fn a_name_is_plain_and_visible() { for ok in ["r.pdf", "Résumé 2026 (final).pdf", "收据.png", "a"] { diff --git a/docs/testing.md b/docs/testing.md index e48d96f..25cb568 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -461,6 +461,122 @@ sidebar (4.35:1), and a first fix (moving colors toward the text color) that turned Everforest's red and green into the same grey; colors keep their hue and change only lightness. +## P2 slices 3 to 8 (2026-10-19 to 2026-10-20) + +From slice 3 on, mutation testing waits for P12 (D-33, owner 2026-10-20: +"put a pause on all the mutation testing … For now I want to focus on +the building"). Each slice was reviewed until a round came back clean and +passes `just check`; what P12 must still cover is listed at the end. + +**Slice 3, opening and closing the books.** The books server on local +sockets (`ledger-mcp` `local.rs`, `admin.rs`, `opening.rs`), the host, its +leases and the agents' bridge (`ledger-host`). `ledger-cli/tests/ +local_server.rs` (the server and its admin socket), `open_while_held.rs` +(the plan's named P2 tests under names of their own: last-holder close, +one host and server, the bridge opening closed books, cold start, restart +with writes across it, close during bursts of posts, SIGTERM and SIGHUP, +a clean host from a hostile bridge, respawn and backoff, no TCP, the app +scope, cancel, malformed input, a frozen host and server, books made +mid-session), `setup.rs`, `one_executable.rs`; unit tests in `ledger-host`, +`admin.rs`, `local.rs`, `opening.rs`; `ledger-store/tests/root.rs` +(adoption). Eight review rounds (13, 22, 9, 4, 4, 10, 3, 3 findings; the +last three found the core clean). The burst tests compare the posts in the +books with the posts answered. + +**Slice 4, access.** Tokens, the door, scopes, the access log and the +owner passphrase (`ledger-store` `auth.rs`, `access.rs`, `chain.rs`, +`passphrase.rs`; `ledger-mcp` `scopes.rs`, `access.rs`): their unit tests, +`local_server.rs` (401/403, a session bound to its token, every call +recorded once with refusals, a damaged token log refusing every agent), +`open_while_held.rs` (the bridge picking up a token made mid-session), +`setup.rs` (the passphrase prompts, at a real terminal). + +**Slice 5, files in and out.** Uploads by hash and size, exports handed +over by the server (`ledger-store` `uploads.rs`, `exports.rs`; `ledger-mcp` +`tools/files.rs`; `wanderbooks upload`/`download`): unit tests (expiry, +the grant replaced or lapsed while bytes arrive, a second send refused), +`local_server.rs`, `one_executable.rs`, `tin_files.rs` and the MCP specs +the import roots' removal changed. Four review rounds. + +**Slice 6, the viewer.** Finding the books in the data root, settings from +the journal, presence through `ops/`, Save a copy and Open, the Export +menu, one builder for the CPA package (`ledger-package`): the viewer's +suites (`exports.rs`, `read_only.rs`, `show.rs`, `kept.rs`, `live.rs` and +the rest), `ledger-package/tests/package.rs`, `ledger-store` `saving.rs` +and `ops.rs`. + +**Slice 7, backups and restore.** `ledger_store::backup`, `Root::restore`, +`ledger-mcp` `backup.rs` and `tools/backup.rs`, the loop in `local.rs`: +`ledger-cli/tests/backups.rs` (17: both targets after a write, the quiet +before a backup, close and catching up at the next open, admin only and +the head unmoved, a target that moved on, a failing target waiting while +the others carry on, a damaged record, restore beside and on a clean +install, of the previewed backup only, with its vault and key, refusing +damage, never overwriting, a failed replace, a restored copy leaving its +original's repository alone, a backup folder never made again, drills on +open and a failed drill flagged), `open_while_held.rs` (`books restore` +and its default id), `backup.rs`'s unit tests (20, among them links and +strangers refused before checkout, target and archive hooks never run, the +owner's hostile git configuration never spoiling a backup, repositories +with settings of their own or something beside them refused, fake gits +for the time limit and the process group), `tests/root.rs` (never moving +books something holds). Five review rounds (about 25, 15, 10, 7 and 3 +findings, all fixed) and a sixth that only attacked the repository guard +(one low finding, fixed). The backup suite passed every run three at a +time. + +**Slice 8, the viewer's menu, setup, no confinement (D-32).** +`ledger-gui/tests/owner_menu.rs` (each item through a fake owner, the +restore checked first, nothing of the books written, busy states; +screenshots looked at), `open_while_held.rs` (`books back-up`, the viewer +holding new books and closing the books, `connect` making anew a token +the keyring lost and saying when an agent refuses, the two lease +intents), `one_executable.rs` (the scaffold gone, and saying so), +`spec_kept.rs` (`isolation` is `same_user (advisory)`). Two review rounds. + +**Tests that mutation found, before the pause.** Pass 1 on slices 3 to 6 +(each crate's own tests, the viewer left out): `ledger-store` 385 mutants +(44 missed), `ledger-package` 137 (38), `ledger-mcp` 497 (247), +`ledger-host` 666 (275), `ledger-cli` 47 (11). Pass 2 (the survivors again +with the end-to-end tests) left 38, 13, 90 and 11 in the first four; the +`ledger-host` pass was stopped. Then: +- `ledger-cli`'s 11 are all in the `#[cfg(not(unix))]` stubs + (`owner_passphrase`, `run_files`, `run_local`, `run_server`): not + compiled on Linux, so equivalent here; +- `ledger-mcp` (`access.rs`, `admin.rs`, `settings.rs`, `tools/files.rs`, + `tools/kept.rs`): all 14 killed (each access record's request id, the + admin socket's four-MiB line, `TokenText`'s and `CallError`'s text, no + server meaning nothing sent, a book's own settings before it holds any, + the caller's token in a printed command, a long name cut to 255 bytes, + a settings file moving into the books). **One real bug:** the command + line took an admin answer longer than four MiB, or cut off before its + newline, whenever the bytes that came still parsed; it is refused now + (`an_answer_is_one_line_of_at_most_four_mib`); +- `ledger-package`: tests for a dotfile's twin, receipts on the year's + first and last days, a vendor's payments from the settings' accounts + alone, the index's amount as written (`package.rs:482`'s debit side is + not equivalent: amounts keep the scale they were written with), the + TIN source's three cases. **Two real bugs**, found reading the naming: + a twin's number went on the last dot of the whole path, so in a folder + with a dot ("a.b") the second "notes" landed in another folder; and a + name of dots alone ("..") could climb out of its folder when the zip is + unpacked. Both fixed + (`a_twins_number_goes_on_its_own_name_and_no_name_climbs_out_of_its_folder`); +- `ledger-store`: a private log stays 0700 (`chain.rs:204`'s two mutants + are not equivalent); the two `flock` flag mutants in `ops.rs` (`|` for + `^` on disjoint bits) are equivalent. The other 34 were triaged, not yet + tested (masking keys one by one, a null TIN, token replacement across + books, the chain's messages, export and upload expiry boundaries, the + short-salt record, the dotfile and no-hard-link paths of `save_new`). + +**For P12:** the whole diff from `168494b` (main before slice 3) to the +tip; `ledger-store`'s 34 triaged survivors; `ledger-mcp`'s `local.rs` (45) +and `server.rs` (31) survivors; `ledger-host`'s pass 2; slices 7 and 8 from +scratch (`ledger-store` `backup.rs` and `root.rs`, `ledger-core` +`settings.rs`, `ledger-mcp` `backup.rs`, `tools/backup.rs`, `local.rs`, +`admin.rs`, `ledger-host` `commands.rs`, `ledger-cli`). The survivor lists +are kept with the run's output, outside the repository. + ## Benchmarks (baseline 2026-10-13) No budget, by decision (the owner, 2026-10-13: the books do little work diff --git a/plan/STATUS.md b/plan/STATUS.md index 941fc35..c84424f 100644 --- a/plan/STATUS.md +++ b/plan/STATUS.md @@ -1,20 +1,22 @@ -# STATUS — 2026-10-17: "Served" P0 and P1 live; P2 next +# STATUS — 2026-10-20: "Served" P0 and P1 live; P2 built, cutover next -## Now: Served (plan/served.md), P0 and P1 live +## Now: Served (plan/served.md), P0 and P1 live, P2 built The 2026-10-16 audit of the first company's books (62 verified findings, 20 engine defects) and the design discussion that followed produced `plan/served.md`: the books become a server that owns its data, agents reach them only through the MCP from anywhere, settings and conventions -move into the journal, no scaffold. Phases P0-P11; the owner's decisions +move into the journal, no scaffold. Phases P0-P13; the owner's decisions of 2026-10-16 are at the top of the plan and bind every phase. P0 turned up defects 21 (the CPA package's IIF ignores `as_of`) and 22 (depreciation's final month plugs mid-month); both are for P3. The owner's bar (2026-10-17): this is bookkeeping software and it has to be perfect. Full verification every phase (review rounds until clean, -`just check`, `just prove`, `cargo mutants --in-diff`, goldens); -economize only on waste. +`just check`, `just prove`, goldens); economize only on waste. Mutation +testing waits for P12 from P2 slice 3 on (D-33, owner 2026-10-20: focus +on building; P12 reruns the whole max-testing pass, on the owner's desktop +or CI). **P0 (guard rails): done.** On `main` at `391634a`, pushed (origin + GitHub): pins, transports doc, tools-only template; the store's format @@ -55,13 +57,30 @@ the MCP, no TINs), privately, by decision, never in this public repo (plan 5.6). P1+ must reproduce them except where the P3 change log adjudicates a difference (cash flow, defect 1). -**Then:** P2 (one `wanderbooks` executable, D-22; the books open while -the app, an agent session or a terminal holds them, no background -service, D-21/D-23 (owner, 2026-10-18); cutover; ask the owner the two open plain-English cutover questions -in plan section 10.3 first; Q-2 is answered, D-20), P3-P11 as planned (P11, added 2026-10-17: one app for Windows, Mac and -Linux, with installers and updaters per D-24: `.dmg`, a per-user Windows -installer, AUR, apt, dnf and AppImage; P2 gained backup restore and the -viewer's menu). +**P2 (one executable, open while held, the cutover): built, slices 1-8.** +Slices 1-2 on `main` earlier. Slices 3-8 (2026-10-19 to 2026-10-20), +each reviewed until a round came back clean: 3, opening and closing the +books (the server on local sockets, the host and its leases, the agents' +bridge; no background service, D-21/D-23); 4, access (tokens in the +keyring, the door, scopes, the access log, the owner passphrase); 5, files +in and out through the server (uploads, exports); 6, the viewer on the data +root and one CPA-package builder (`ledger-package`); 7, backups and restore +(D-30, D-31: plain git to a remote and a folder, drills, +`wanderbooks books restore`); 8, the viewer's Books menu, `connect`, +`books back-up`, no confinement (D-32), the scaffold deleted. Then the +tests mutation had found before the pause (D-33), with three real bugs +fixed: the admin answer's four-MiB line, and the CPA package's file +naming (a twin's number in another folder; a name of dots climbing out of +its folder). `docs/testing.md` lists what P12 must cover. + +**Next:** slice 9, the cutover of the first company's books (plan 7, P2 +cutover; ask the owner Q-3 and the backup addresses first, one at a time), +then P3-P12 as planned (P3's design under way: one brief per defect group, +each attacked by a reviewer). P11: one app for Windows, Mac and Linux +(D-24). P12: max testing again (D-33). P13: bookspec (D-34), a formal, +sourced accounting specification first, then its own formal validation +and a conformance harness for any engine; P3's regression tests are its +first examples. --- diff --git a/plan/served.md b/plan/served.md index f6134d0..1027c78 100644 --- a/plan/served.md +++ b/plan/served.md @@ -951,9 +951,10 @@ The first company keeps working between phases. **Tests and accept:** - A named regression test per defect, built from its 2026-10-16 reproduction (anonymized fixture). +- Each of those regression tests is also written as a bookspec example (P13, D-34) as it is built: the rule it shows, its source, the setup, the events and the exact expected results. They are the spec's first examples. - Goldens recomputed by hand. - GnuCash differential re-adjudicated. -- 0 unexplained mutation survivors in `ledger-core` and `ledger-import`. +- 0 unexplained mutation survivors in `ledger-core` and `ledger-import` (run in P12, D-33). - The the first company change log is adjudicated. ### P4: Drafts, reversal as draft, the evidence gate, digest (defects 2, 5, 6) @@ -1060,7 +1061,7 @@ The first company keeps working between phases. - `unwrap_only_in_human_handler`; `vault_pubkey_swap_detected_by_verify`; `reseal_preserves_last4_and_kind`; `cpa_package_with_tins_human_only`. - Lost passphrase is recoverable with the recovery key. - GUI `tin_copy`; `gui_unlock_zeroizes` (no plaintext TIN or private key left in the GUI's memory after the copy). -- Mutation testing on `ledger-vault`. +- Mutation testing on `ledger-vault` (in P12, D-33). **Accept:** - The the first company K-1 member worksheet and 1099 worksheet with TINs, produced through the human link, match the P0-era output byte for byte apart from timestamps. @@ -1176,6 +1177,62 @@ Run from clients that hold only a URL and a credential: Claude Code started in ` - An update, on each system, leaves every book's head and goldens unchanged, and an agent session open across it carries on. - A backup made on one system restores on each of the others. +### P12: Max testing, again (D-33) + +The whole max-testing pass of 2026-10-14 (`docs/testing.md`), run again over everything built since, on the owner's desktop once it is set up (the laptop stays usable: owner, 2026-10-20). Building comes first; this phase holds the testing every phase from P2 slice 3 on deferred to it. + +**Scope:** +- **Mutation testing** of every crate but the viewer (no mutation runs on the GUI, owner 2026-10-14; its two musts are tested instead) over the whole diff from `main` as P2 slice 3 found it (`168494b`) to the tip: every survivor killed by a test or documented as equivalent in `docs/testing.md`, one section per phase. Each crate's own tests first, then the survivors again with the end-to-end `ledger-cli` tests (`--iterate`). +- **Carried in from P2:** slices 3–6 had pass 1 on every crate and pass 2 on all but `ledger-host`; its survivors were partly triaged (`ledger-cli`'s 11 are `#[cfg(not(unix))]` stubs, equivalent on Linux; one area of `ledger-mcp` killed, which found and fixed the admin reply-length bug). Slices 7 and 8 had none. The triage notes go in `docs/testing.md`. +- **Fuzzing:** every `cargo-fuzz` target, the old and the new (5.x), each for a set time without a crash; any crash becomes a regression test. +- **Property tests** at their full case counts (`STORE_CASES` and the rest at the documented maximum), and the GnuCash differential re-run. +- **Proofs:** `just prove` (Kani with no vacuous covers, Lean with standard axioms only), every harness and theorem added since. +- **Where:** the desktop, or GitHub Actions' scheduled workflow (`.github/workflows/scheduled.yml`, already sharded to fit its six-hour jobs), or both. +- **Load:** the runs never take every core (owner, 2026-10-20): cargo-mutants with an explicit `--jobserver-tasks`, one mutation run at a time, leaked test daemons reaped. + +**Accept:** 0 unexplained mutation survivors workspace-wide (the viewer aside); every fuzz target clean for its set time; `just prove` and `just check` green; `docs/testing.md` records each crate's counts and every equivalent mutant with its reason. + +### P13: bookspec, a formal accounting specification (D-34) + +**bookspec is a specification first.** A normative, formally written statement of how books are kept, every rule sourced to its authority, independent of any engine. Built on it, second and third: its own formal validation, and a conformance harness that any accounting engine implementing its interface can be tested with. Owner, 2026-10-20: "a FORMALLY written accounting spec … properly sourced with actual references, it's thorough, it might even have its OWN formal validation. It includes symbolic specs as well as concrete examples. It's useful for ANY accounting engine testing", and "bookspec is a SPEC first, and then it can also include formal methods/validation/proofs as well as a test harness that could be run against an accounting engine that conforms to the interface." + +**What it grows from:** `docs/spec/rules.md` (every rule with its AUTHORITY / CONVENTION / DECISION status and source), the vendored sources in `docs/spec/sources/`, the Lean theorems in `verify/lean/` (entries, ledger, statements, closing, apportionment), the goldens, and P3's named regression tests, written as bookspec examples from the start (P3). + +**1. The specification.** A document in numbered sections and stable rule identifiers (an identifier never changes meaning; the spec is versioned): +- **Definitions and the model:** accounts (types, normal sides), money (exact decimal, currency, scale), entries and lines, periods and the fiscal year, basis (cash, accrual), parties and instruments, assets; the statements and reports defined over them. +- **Rules, each with:** + - its **statement** in normative language (MUST, MUST NOT, MAY); + - its **source**, pinned exactly (an IRC section, a Treasury Regulation, IRS form instructions or a publication with its revision year, a FASB ASC topic-subtopic-section-paragraph) and its **status**: AUTHORITY (a source states it), CONVENTION (common practice, best reference given), or CHOICE (the authorities allow options: direct or indirect cash flow, FIFO or average cost, an apportionment rule; the spec names the options and an engine declares its own); + - its **symbolic form**: the rule as a formula over the model, with variables and their constraints (A > 0, 0 ≤ r ≤ 1) and the result as an expression; + - its **concrete examples**: worked cases (setup: the chart and settings, rounding mode and scale included; events in order; the exact expected results to the cent, or a refusal with its reason), with the edges that matter (odd cents and rounding, reversals, mid-period dates, the first and last day of a period, zero amounts, disposals, closed periods, multiple currencies); + - its **applicability**: cash or accrual, book or tax, jurisdiction, and for every tax rule the tax years it holds for (thresholds and tables change by year). +- **Three kinds of rule:** laws that hold for all books (debits equal credits, A = L + E, the trial balance balances, net income articulates to equity, the cash-flow sections sum to the change in cash); sourced rules with definite answers (the de minimis safe harbor, the 12-month rule, MACRS tables and conventions, 1099 thresholds by year, 704(c) built-in gain, 723 carryover basis); and choices, parameters rather than one right answer. +- **Coverage, written incrementally in order of need:** double entry and the account model; journals, reversals and corrections; trial balance, balance sheet and income statement; accrual and cash basis; cash flows, direct and indirect (ASC 230); periods, closes and the equity roll-forward; partnerships (capital accounts, allocations, 704(b) and 704(c), 723, K-1 figures); fixed assets and depreciation, book and tax (Pub. 946, MACRS); prepaids, amortization and capitalization (Treas. Reg. 1.263(a)-1(f), -4(f)); receivables, payables and aging; revenue (ASC 606) and deferred revenue; 1099 reporting; then payroll, sales tax, inventory and multiple currencies (ASC 830), each large on its own; rounding and apportionment throughout. Interchange formats (IIF, CSV) are an appendix, marked as formats, not accounting. + +**2. Formal validation of the spec.** +- The model and every symbolic rule in Lean 4: the model is inhabited, the rules are consistent with each other (no two demand different results on the same books), and the laws are theorems (standard axioms only, no `sorry`). +- Every concrete example is derived from its symbolic rule by the spec's own checker (Rust, exact decimal money): an example whose written expectation disagrees with its derivation fails the spec's build. No expected value rests on one engine's output (the GnuCash-is-not-the-authority lesson, applied to every engine, wanderbooks included). +- Every citation resolves to a vendored or linked source, and no rule lacks one. + +**3. The conformance harness.** +- **Rust throughout** (owner, 2026-10-20: "We are rust people"): the checker, the generator and the harness are Rust crates in bookspec's own Cargo workspace; no JavaScript anywhere. +- **The interface** an engine implements is a Rust trait (`Engine`): load a setup, apply events, answer queries (balances, report lines) or refuse with a reason, and declare its choices and the parts of the spec it claims. An engine not written in Rust is reached through an adapter that implements the trait (over its API, its files or its command line). +- **The cases** are the spec's examples plus cases generated from the symbolic rules (variables drawn within their constraints, expectations computed from the expressions, as `proptest` strategies), written as TOML files the Rust checker validates against their schema. +- **The runner** reports per rule: pass, fail, or not claimed. A disagreement between engines is a lead, settled from the sources. +- **Engines:** wanderbooks first (its engine crates directly, and end to end through its MCP); GnuCash through the oracle (`docs/oracle.md`), hledger and Beancount feasible, QuickBooks through its API perhaps. + +**Sources: United States first, canonical only** (owner, 2026-10-20: "the source should come from that national … association thing that we used earlier in this project. Or any other reputable, canonical source of accounting rules and practices and procedures"). The first edition is US GAAP and US federal tax; other jurisdictions and IFRS are later editions. Every rule cites the highest source that settles it, in this order: +- **Accounting: the FASB Accounting Standards Codification** (the Financial Accounting Standards Board, read at asc.fasb.org as `docs/spec/sources/fasb-asc.md` was in this project). By its own terms the Codification is the single source of authoritative nongovernmental US GAAP (ASC 105-10-05-1), so an accounting rule's source is an ASC topic-subtopic-section-paragraph. +- **Tax: the law and the IRS.** The Internal Revenue Code, Treasury Regulations, then IRS revenue rulings and procedures, notices, form instructions and publications (with their revision year). +- **Where the Codification leaves a question open,** the nonauthoritative sources ASC 105-10-05-3 itself names, in its order: practices widely recognized and prevalent, FASB Concepts Statements (the conceptual framework), AICPA Issues Papers, IFRS, pronouncements of professional associations and regulators, the AICPA's Technical Practice Aids, and accounting textbooks, handbooks and articles (the list is checked against the paragraph when the spec is written). The Big Four firms' published guides (Deloitte's DART roadmaps among them) may help with reading a paragraph, but never stand as a rule's source. A rule resting only on these is marked CONVENTION. +- **Never a source:** what one engine does (wanderbooks and GnuCash included), what one accountant says, or an unsourced website. + +**Copyright.** Rules and facts aren't copyrightable; their expression is. The Codification is copyrighted by the Financial Accounting Foundation, so bookspec states each rule in its own words with the paragraph cited, and quotes no more than the Foundation permits. IFRS text is treated likewise. IRS and Treasury text is public domain and may be quoted. Textbooks are used only under a compatible license (no NonCommercial or ShareAlike text). Public: the spec under CC BY 4.0, the checker and harness under MIT OR Apache-2.0. + +**Where it lives:** its own public repository (`bookspec`), separately versioned, which wanderbooks consumes: `docs/spec/rules.md` points each rule at its bookspec identifier, the engine's `SOURCE:` blocks cite bookspec, and the harness runs against wanderbooks in `just check` for the parts it claims. + +**Accept:** the spec's build green (Lean as above; every example matching its derivation; every citation resolved); wanderbooks passes every case for the parts it claims, with each unclaimed part listed; a second engine (GnuCash, through the oracle) runs through the harness, its differences settled from the sources. + --- ## 9. Risks and mitigations @@ -1257,6 +1314,8 @@ The owner decisions of 2026-10-16 (at the top of this document) replace the form | D-30 | Q-1 answered: where the backups go (owner, 2026-10-19, asked "Where should the books' automatic backups go?" with a private git repo, a folder or other disk, or both: chose "Both") | **Two targets.** The archive pushes to a private git repository the owner names and also copies to a folder the owner names (an external disk or a synced folder); both hold the archive (not encrypted since D-31). The repository's address and the folder are asked for when backups are first set up (slice 7, or at the cutover for the first company), and kept as the book's backup settings. | | D-31 | Backups are not encrypted (owner, 2026-10-19, asked where to keep the backup's recovery key: "Why encrypt them? How about we let the backup system handle the security???? You're over complicating everything.") | **A backup is the book as it is, in git.** No age encryption, no archive key, no recovery key: the archive is a plain git repository of the book's journal, receipts, vault (as the vault keeps it) and keys, one commit per backup, pushed to the owner's private git repository and to a git repository in the owner's folder (D-30). The destinations are trusted to keep it private. This replaces 3.7's and 3.4's age encryption on push and 3.5's archive key. | | D-32 | No self-confinement (owner, 2026-10-20, asked how much of the planned Landlock/seccomp sandboxing of the server and the viewer, with the viewer's helper process, to build now that the server runs git for its backups: chose "None") | **No Landlock, no seccomp, no helper process for confinement.** The viewer stays read-only by design and by test (it never writes the books; its menu hands work to the server, through its admin socket as the command line does); agents still reach the books only through the MCP. Isolation is reported everywhere as `same_user (advisory)`. This replaces the self-confinement of P2's scope, 3.9's confined modes and their helper, and the archive-vs-Landlock decision row. | +| D-33 | Mutation testing waits for P12 (owner, 2026-10-20: "Let's just put a pause on all the mutation testing. Let's add a P12 that runs the whole \"max test\" workflow again. By then maybe I'll have my (much beefier) desktop PC setup and we can run all that shit over there. For now I want to focus on the building.") | **Each phase from P2 slice 3 on is built, reviewed until clean and passes `just check` and `just prove`, without its mutation run.** P12 runs the whole max-testing pass (mutation, fuzzing, full property runs, proofs) over everything since P1, on the desktop. Each phase records in `docs/testing.md` what P12 must cover. | +| D-34 | bookspec (owner, 2026-10-20: "Let's add a P13 which is a bookspec project. This is a FORMALLY written accounting spec … properly sourced with actual references, it's thorough, it might even have its OWN formal validation. It includes symbolic specs as well as concrete examples. It's useful for ANY accounting engine testing." Then: "bookspec is a SPEC first, and then it can also include formal methods/validation/proofs as well as a test harness that could be run against an accounting engine that conforms to the interface.") | **P13 writes bookspec**, its own public project: first the specification (every rule stated normatively, sourced, with a symbolic form and concrete examples), then its formal validation (Lean model, consistency, the laws as theorems, every example derived), then a conformance harness for any engine implementing its interface. wanderbooks is the first engine tested; `docs/spec/rules.md` and the Lean theorems seed it; P3's regression tests are its first examples. | ### 10.2 Deferred @@ -1293,7 +1352,7 @@ The cutover's check adds: the project folder holds none of the above, and `wande ## 11. Later: hosted service (not built in this plan) -A paid, multi-tenant service in which an operator runs the books for customers. P0–P11 build none of this. It is recorded here so the design stays compatible with it. +A paid, multi-tenant service in which an operator runs the books for customers. P0–P12 build none of this. It is recorded here so the design stays compatible with it. **Shape.** - Per-tenant pair. `front` (`ledger-mcp front --tenant `, user ID `wb-f-`) does OAuth 2.1 (3.3), passkeys, consent, the human pages and token validation; its Landlock denies `books/`, so it **never** reads tenant disk. `worker` (`ledger-mcp worker --tenant --socket`, user ID `wb-t-`, `DynamicUser=`) serves the MCP, uploads, downloads and the replica for its own tenant only, and learns identity only from `X-WB-Principal` on its 0660 socket. Caddy handles TLS and routing and holds no authority. No component holds authority across tenants. Both roles ship in one binary.