Files
wanderbooks/crates/ledger-cli/tests/one_executable.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

260 lines
8.7 KiB
Rust

//! One executable (plan D-22): `wanderbooks` runs the books server
//! (`server`) and the viewer (`gui`) as modes of itself, each with its own
//! flags, and the server-only build has no viewer.
#[cfg_attr(not(unix), allow(unused_imports))]
use std::process::{Command, Stdio};
#[cfg(all(feature = "gui", target_os = "linux"))]
mod common;
fn wanderbooks() -> Command {
Command::new(env!("CARGO_BIN_EXE_wanderbooks"))
}
#[test]
fn each_mode_names_itself_in_its_usage() {
let out = wanderbooks().args(["server", "--help"]).output().unwrap();
assert!(out.status.success());
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("usage: wanderbooks server [--root DIR]"),
"{err}"
);
let out = wanderbooks().arg("--help").output().unwrap();
let err = String::from_utf8_lossy(&out.stderr);
for line in ["wanderbooks gui [BOOK", "wanderbooks mcp --book ID"] {
assert!(err.contains(line), "{line:?} missing from {err}");
}
}
#[cfg(feature = "gui")]
#[test]
fn the_viewer_names_itself_in_its_usage() {
let out = wanderbooks().args(["gui", "--help"]).output().unwrap();
assert!(out.status.success());
let text = String::from_utf8_lossy(&out.stdout);
assert!(
text.contains("usage: wanderbooks gui [--root PATH] [--book ID]"),
"{text}"
);
}
#[test]
fn a_mode_refuses_what_it_does_not_know() {
let modes: &[&str] = if cfg!(feature = "gui") {
&["gui", "server"]
} else {
&["server"]
};
for &mode in modes {
let out = wanderbooks()
.args([mode, "--no-such-flag"])
.output()
.unwrap();
assert_eq!(out.status.code(), Some(2), "{mode}: {out:?}");
}
// A mode's flags are its own: the CLI's `--force` means nothing there.
let out = wanderbooks().args(["server", "--force"]).output().unwrap();
assert_eq!(out.status.code(), Some(2), "{out:?}");
}
#[test]
fn the_books_folder_scaffold_is_gone_and_says_so() {
for gone in ["new", "init"] {
let out = wanderbooks().args([gone, "acme"]).output().unwrap();
assert_eq!(out.status.code(), Some(2), "{gone}: {out:?}");
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains(&format!("`wanderbooks {gone}` is gone")),
"{err}"
);
assert!(err.contains("wanderbooks books create ID"), "{err}");
}
}
#[test]
fn a_mode_comes_first() {
for mode in ["gui", "server"] {
let out = wanderbooks().args(["--codex", mode]).output().unwrap();
assert_eq!(out.status.code(), Some(2), "{mode}: {out:?}");
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains(&format!("`{mode}` must come first")), "{err}");
}
}
#[test]
fn a_server_that_cannot_start_says_why_and_exits_1() {
// A flag missing its value is an error, not a usage slip (exit 2).
let out = wanderbooks().args(["server", "--root"]).output().unwrap();
assert_eq!(out.status.code(), Some(1), "{out:?}");
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("error: --root needs a folder"), "{err}");
}
/// With no display to open a window on, the viewer fails and says so
/// (Linux only: elsewhere there is always a display, and a window would
/// open).
#[cfg(all(feature = "gui", target_os = "linux"))]
#[test]
fn a_viewer_that_cannot_open_a_window_exits_1() {
// Empty books in a data root of the test's own, and a home of its
// own: the viewer opens no loose books folder (D-44), and it reads its
// preferences from the home, never this account's.
let home = common::Home::new("one-exe");
let server = common::Server::start(&home);
home.create(common::BOOK, "USD");
assert!(server.close().success());
let mut gui = wanderbooks();
gui.args(["gui", "--root"])
.arg(home.root())
.args(["--book", common::BOOK]);
common::clean_env(&mut gui, home.dir.path());
let mut child = gui
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let deadline = std::time::Instant::now() + common::TIMEOUT;
let status = loop {
if let Some(status) = child.try_wait().unwrap() {
break status;
}
if std::time::Instant::now() > deadline {
let _ = child.kill();
let _ = child.wait();
panic!("the viewer neither opened nor failed in time");
}
std::thread::sleep(std::time::Duration::from_millis(50));
};
assert_eq!(status.code(), Some(1), "{status}");
let mut err = String::new();
std::io::Read::read_to_string(&mut child.stderr.take().unwrap(), &mut err).unwrap();
assert!(err.contains("error: "), "{err}");
}
#[cfg(not(feature = "gui"))]
#[test]
fn the_server_only_build_has_no_viewer() {
let out = wanderbooks().args(["gui", "--help"]).output().unwrap();
assert_eq!(out.status.code(), Some(2), "{out:?}");
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("the server-only build: it has no viewer"),
"{err}"
);
}
/// The books server needs a data root: with none, it says to run setup.
#[test]
fn the_books_server_needs_a_root() {
// No root yet: the books server says to run setup.
let empty = tempfile::Builder::new()
.prefix("wb-one-noroot-")
.tempdir()
.unwrap();
let out = wanderbooks()
.args(["server", "--root"])
.arg(empty.path().join("none"))
.stdin(std::process::Stdio::null())
.output()
.unwrap();
assert_eq!(out.status.code(), Some(2), "{out:?}");
assert!(
String::from_utf8_lossy(&out.stderr).contains("run `wanderbooks setup` first"),
"{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");
}