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
260 lines
8.7 KiB
Rust
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");
|
|
}
|