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

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

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

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

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

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

118 lines
4.4 KiB
Rust

//! Starting the books server on books it can't serve: books another
//! program holds, and books a newer wanderbooks wrote. Oracle: ledger-mcp
//! local.rs INVARIANT 3 (every book is tried before the server says it is
//! ready; one that won't open is reported with why, on its route and in
//! the list, and the others are served) and rules.md B-27 (newer books
//! are refused as newer, never as corrupt, and left as they are).
use common::{Home, Server, Wire};
use ledger_mcp::admin::{Request, Response};
use serde_json::json;
use std::time::{Duration, Instant};
mod common;
/// New books `id` written in `home`'s root by the store itself, before a
/// server opens them; the store, still open.
fn written(home: &Home, id: &str) -> ledger_store::Store {
let dir = home.book_dir(id);
let store = ledger_store::Store::create(&dir, ledger_core::Currency::USD).unwrap();
common::private_folder(&dir);
store
}
/// What the server's list says of `id`: whether it is open, and why not.
fn listed(home: &Home, id: &str) -> (bool, Option<String>) {
match home.admin(Request::List) {
Response::Books(list) => {
let b = list
.into_iter()
.find(|b| b.id == id)
.unwrap_or_else(|| panic!("{id} isn't listed"));
(b.open, b.problem)
}
other => panic!("{other:?}"),
}
}
/// A request to `id`'s route is refused before it runs, with why.
fn refused_at_the_door(home: &Home, id: &str, why: &str) {
let mut wire = Wire::with_token(home, id, None);
wire.send(
&json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
"protocolVersion": common::PROTOCOL, "capabilities": {},
"clientInfo": {"name": "t", "version": "0"}}}),
);
let refusal = wire.refused.clone().expect("refused");
assert!(refusal.not_run, "{refusal:?}");
assert!(refusal.body.contains(why), "{refusal:?}");
home.assert_hidden(&refusal.body);
}
/// A writer that keeps the books is a conflict: the server reports those
/// books not open, with why, serves the others, and never hangs on them.
#[test]
fn a_held_lock_is_refused_not_waited_on_forever() {
let home = Home::new("start-locked");
let _holder = written(&home, "acme");
drop(written(&home, "beta"));
let start = Instant::now();
let server = Server::start(&home);
assert!(
start.elapsed() < Duration::from_secs(60),
"the server waited on a held lock"
);
let (open, problem) = listed(&home, "acme");
assert!(!open);
let problem = problem.expect("why");
assert!(
problem.contains("another program has these books open"),
"{problem}"
);
refused_at_the_door(&home, "acme", "another program has these books open");
// The others are served.
assert_eq!(listed(&home, "beta"), (true, None));
let mut beta = Wire::new(&home, "beta");
beta.initialize("t");
let chart = beta.call("books.chart", json!({}));
assert_eq!(chart["result"]["isError"], json!(false), "{chart}");
assert!(server.close().success());
}
/// Books written by a newer wanderbooks: the server won't open them, and
/// says to update it, not that the books are corrupt (rules.md B-27); it
/// leaves them as they are.
#[test]
fn newer_books_are_refused_as_newer_not_corrupt() {
let home = Home::new("start-newer");
drop(written(&home, "acme"));
let dir = home.book_dir("acme");
let mut w = ledger_store::JournalWriter::open(&dir.join(ledger_store::JOURNAL_FILE)).unwrap();
w.append("journal.format_set", json!({"format": 3, "features": []}))
.unwrap();
// The next line chains onto it, so its bytes are checked (B-27).
w.append(
"fx.rate_set",
json!({"currency": "EUR", "date": "2026-10-01", "rate": "1.10"}),
)
.unwrap();
drop(w);
let journal = std::fs::read(dir.join(ledger_store::JOURNAL_FILE)).unwrap();
let server = Server::start(&home);
let (open, problem) = listed(&home, "acme");
assert!(!open);
let err = problem.expect("why");
assert!(
err.contains("written by a newer version of wanderbooks"),
"{err}"
);
assert!(err.contains("Update wanderbooks"), "{err}");
assert!(!err.contains("corrupt"), "{err}");
refused_at_the_door(&home, "acme", "Update wanderbooks");
assert!(server.close().success());
assert_eq!(
std::fs::read(dir.join(ledger_store::JOURNAL_FILE)).unwrap(),
journal
);
}