P2: tests mutation found, three bugs fixed; plan P12 and P13

Mutation testing pauses until P12 (D-33, owner 2026-10-20), which reruns
the whole max-testing pass over everything since P1. Before the pause,
pass 1 on slices 3-6 and its triage gave tests in ledger-store,
ledger-package and ledger-mcp, and three real bugs, fixed:
- the command line took an admin answer longer than four MiB, or cut off
  before its newline, whenever the bytes that came still parsed;
- The CPA package put a twin's number on the last dot of the whole path,
  so in a folder with a dot the second file landed in another folder;
- a document or category named of dots alone ("..") could climb out of
  its folder when the CPA package is unpacked.
The plan gains P13, bookspec (D-34): a formal, sourced accounting
specification first (rules with symbolic forms and worked examples), then
its own formal validation and a conformance harness any engine can be
tested against, seeded by P3's regression tests. docs/testing.md records
slices 3-8 and what P12 must cover; plan/STATUS.md says where P2 stands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-20 11:14:22 -06:00
1 parent 3cf359a0cd
commit d5c9522ad1
24 files changed
+2937 -20

No files matched your search

+105
View File
@@ -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<u8>,
) -> (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<u8> {
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
);
}
}