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
118 lines
4.4 KiB
Rust
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
|
|
);
|
|
}
|