P2 slice 3: opening and closing the books (plan 3.9, D-21, D-23)
One wanderbooks executable. The books are open while the viewer, an agent session or a terminal holds them: a host process takes leases on a socket, starts the server (the hidden mode `wanderbooks server`) with a pipe on its input, and closes it 10 s after the last lease ends. No background service. One host even in a race (run/host.lock). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
1 parent
168e5c9846
commit
a298d154b6
31 files changed
+11937
-112
No files matched your search
@@ -17,14 +17,20 @@ default = ["gui"]
|
||||
gui = ["dep:ledger-gui"]
|
||||
|
||||
[dependencies]
|
||||
ledger-host = { path = "../ledger-host" }
|
||||
ledger-mcp = { path = "../ledger-mcp" }
|
||||
ledger-store = { path = "../ledger-store" }
|
||||
ledger-gui = { path = "../ledger-gui", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
chrono = "0.4"
|
||||
libc = "0.2"
|
||||
reqwest = "0.13"
|
||||
rmcp = { version = "3", features = ["client", "transport-streamable-http-client-reqwest"] }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
|
||||
ledger-config = { path = "../ledger-config" }
|
||||
ledger-core = { path = "../ledger-core" }
|
||||
ledger-vault = { path = "../ledger-vault" }
|
||||
serde_json = "1"
|
||||
toml = "1"
|
||||
tempfile = "3"
|
||||
|
||||
@@ -394,11 +394,19 @@ fn print_usage() {
|
||||
eprintln!(" wanderbooks gui [DIR | --data-dir PATH ...]");
|
||||
eprintln!(" wanderbooks dev-stdio [--data-dir PATH ...]");
|
||||
eprintln!(" wanderbooks setup [--root PATH] [--home DIR]");
|
||||
eprintln!(" wanderbooks books list | create ID | adopt DATA-DIR --id ID");
|
||||
eprintln!(" wanderbooks mcp --book ID");
|
||||
eprintln!(" wanderbooks serve | close | doctor");
|
||||
eprintln!();
|
||||
eprintln!(" new make a books directory and start the agent in it (alias: init)");
|
||||
eprintln!(" gui open the live, read-only view of a books directory");
|
||||
eprintln!(" dev-stdio run the books server over stdio (`dev-stdio --help` for its flags)");
|
||||
eprintln!(" setup make the books' private folder (only your account can open it)");
|
||||
eprintln!(" books list, start or adopt books (`books --help`)");
|
||||
eprintln!(" mcp connect an agent to books, over stdio (what `claude mcp add` runs)");
|
||||
eprintln!(" serve hold the books open until Ctrl-C");
|
||||
eprintln!(" close close the books now: everything holding them lets go");
|
||||
eprintln!(" doctor whether the books are open, what holds them, what went wrong");
|
||||
eprintln!(" DIR the books directory (default: current directory)");
|
||||
eprintln!(" --force rewrite the scaffold in an existing books directory");
|
||||
eprintln!(" --no-start don't start the agent afterwards");
|
||||
@@ -847,7 +855,14 @@ fn run_setup(args: Vec<String>) -> i32 {
|
||||
look there (tests and unusual setups only)"
|
||||
);
|
||||
}
|
||||
let xdg = env::var_os("XDG_DATA_HOME").map(PathBuf::from);
|
||||
// XDG_DATA_HOME is this account's, for this account's home: with
|
||||
// another home given (tests), it is never read, so it can't aim at
|
||||
// this account's real books.
|
||||
let xdg = if account.as_ref() == Some(&home) {
|
||||
env::var_os("XDG_DATA_HOME").map(PathBuf::from)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match ledger_store::root::Root::setup(&home, root.as_deref(), xdg.as_deref()) {
|
||||
Ok((_, report)) => {
|
||||
let what = if report.created {
|
||||
@@ -884,6 +899,62 @@ fn run_dev_stdio(args: Vec<String>) -> i32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// `wanderbooks server`: the books server for the data root, on its
|
||||
/// local sockets (plan 3.9). The host starts it.
|
||||
#[cfg(unix)]
|
||||
fn run_server(args: Vec<String>) -> i32 {
|
||||
match ledger_mcp::local::run("wanderbooks server", args) {
|
||||
Ok(()) => 0,
|
||||
Err(e) => match e.downcast_ref::<ledger_mcp::Exit>() {
|
||||
Some(exit) => exit.0,
|
||||
None => {
|
||||
eprintln!("error: {e}");
|
||||
1
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The modes that open and hold the books (plan 3.9): the host, the
|
||||
/// helper, the agents' bridge, and the owner's commands.
|
||||
#[cfg(unix)]
|
||||
fn run_local(mode: &str, args: Vec<String>) -> i32 {
|
||||
let prog = format!("wanderbooks {mode}");
|
||||
let result = match mode {
|
||||
"host" => ledger_host::host::run(&prog, args),
|
||||
"helper" => ledger_host::helper::run(&prog, args),
|
||||
"mcp" => ledger_host::bridge::run(&prog, args),
|
||||
"serve" => ledger_host::commands::serve(&prog, args),
|
||||
"close" => ledger_host::commands::close(&prog, args),
|
||||
"doctor" => ledger_host::commands::doctor(&prog, args),
|
||||
_ => ledger_host::commands::books(&prog, args),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => 0,
|
||||
Err(e) => match e.downcast_ref::<ledger_host::Exit>() {
|
||||
Some(exit) => exit.0,
|
||||
None => {
|
||||
eprintln!("error: {e}");
|
||||
1
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The modes that hold the books, where local sockets aren't built yet.
|
||||
#[cfg(not(unix))]
|
||||
fn run_local(_mode: &str, _args: Vec<String>) -> i32 {
|
||||
eprintln!("error: opening the books this way comes to this system with P11");
|
||||
2
|
||||
}
|
||||
|
||||
/// `wanderbooks server` where local sockets aren't built yet.
|
||||
#[cfg(not(unix))]
|
||||
fn run_server(_args: Vec<String>) -> i32 {
|
||||
eprintln!("error: the books server's local sockets come to this system with P11");
|
||||
2
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// The modes run in this process and take their own flags.
|
||||
let mut all = env::args().skip(1);
|
||||
@@ -891,6 +962,11 @@ fn main() {
|
||||
Some("gui") => std::process::exit(run_gui(all.collect())),
|
||||
Some("dev-stdio") => std::process::exit(run_dev_stdio(all.collect())),
|
||||
Some("setup") => std::process::exit(run_setup(all.collect())),
|
||||
Some("server") => std::process::exit(run_server(all.collect())),
|
||||
Some(mode @ ("host" | "helper" | "mcp" | "serve" | "close" | "books" | "doctor")) => {
|
||||
let mode = mode.to_string();
|
||||
std::process::exit(run_local(&mode, all.collect()))
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let mut dir: Option<PathBuf> = None;
|
||||
@@ -921,7 +997,11 @@ fn main() {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(mode @ ("gui" | "dev-stdio" | "setup")) = cmd.as_deref() {
|
||||
if let Some(
|
||||
mode @ ("gui" | "dev-stdio" | "setup" | "server" | "host" | "helper" | "mcp" | "serve"
|
||||
| "close" | "books" | "doctor"),
|
||||
) = cmd.as_deref()
|
||||
{
|
||||
eprintln!("`{mode}` must come first: wanderbooks {mode} [its own flags]");
|
||||
std::process::exit(2);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -103,6 +103,14 @@ fn each_mode_names_itself_in_its_usage() {
|
||||
"{err}"
|
||||
);
|
||||
|
||||
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 [
|
||||
@@ -129,9 +137,9 @@ fn the_viewer_names_itself_in_its_usage() {
|
||||
#[test]
|
||||
fn a_mode_refuses_what_it_does_not_know() {
|
||||
let modes: &[&str] = if cfg!(feature = "gui") {
|
||||
&["dev-stdio", "gui"]
|
||||
&["dev-stdio", "gui", "server"]
|
||||
} else {
|
||||
&["dev-stdio"]
|
||||
&["dev-stdio", "server"]
|
||||
};
|
||||
for &mode in modes {
|
||||
let out = wanderbooks()
|
||||
@@ -150,7 +158,7 @@ fn a_mode_refuses_what_it_does_not_know() {
|
||||
|
||||
#[test]
|
||||
fn a_mode_comes_first() {
|
||||
for mode in ["gui", "dev-stdio"] {
|
||||
for mode in ["gui", "dev-stdio", "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);
|
||||
@@ -218,3 +226,65 @@ fn the_server_only_build_has_no_viewer() {
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Books in the data root are the books server's alone (plan 3.1): the
|
||||
/// test server refuses them, and the books server needs a root.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn dev_stdio_refuses_books_in_the_private_folder() {
|
||||
let home = tempfile::Builder::new()
|
||||
.prefix("wb-one-root-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let out = wanderbooks()
|
||||
.args(["setup", "--home"])
|
||||
.arg(home.path())
|
||||
.env_remove("XDG_DATA_HOME")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
let root = home.path().join(".local/share/wanderbooks");
|
||||
for inside in [
|
||||
root.join("books/acme"),
|
||||
root.clone(),
|
||||
root.join("books/a/b"),
|
||||
] {
|
||||
let out = wanderbooks()
|
||||
.args(["dev-stdio", "--data-dir"])
|
||||
.arg(&inside)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(out.status.code(), Some(2), "{inside:?}: {out:?}");
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(err.contains("only the books server opens"), "{err}");
|
||||
assert!(!inside.join("journal.jsonl").exists(), "{inside:?}");
|
||||
}
|
||||
assert!(!root.join("books/acme").exists());
|
||||
// A relative path, from inside the root.
|
||||
let out = wanderbooks()
|
||||
.args(["dev-stdio", "--data-dir", "acme"])
|
||||
.current_dir(root.join("books"))
|
||||
.stdin(std::process::Stdio::null())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(out.status.code(), Some(2), "{out:?}");
|
||||
assert!(!root.join("books/acme").exists());
|
||||
|
||||
// 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:?}"
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -69,16 +69,20 @@ fn setup_makes_the_private_folder_and_never_names_it() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_root_elsewhere_is_noted_and_xdg_is_honoured() {
|
||||
fn a_root_elsewhere_is_noted_for_next_time() {
|
||||
// (XDG_DATA_HOME is honoured only for this account's own home, which
|
||||
// a test never touches: Root::setup's tests cover it.)
|
||||
let home = tempfile::Builder::new()
|
||||
.prefix("wb-setup-xdg-")
|
||||
.prefix("wb-setup-else-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let xdg = home.path().join("xdg");
|
||||
let elsewhere = home.path().join("elsewhere").join("wanderbooks");
|
||||
let out = wanderbooks()
|
||||
.args(["setup", "--home"])
|
||||
.arg(home.path())
|
||||
.env("XDG_DATA_HOME", &xdg)
|
||||
.arg("--root")
|
||||
.arg(&elsewhere)
|
||||
.env_remove("XDG_DATA_HOME")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
@@ -86,13 +90,10 @@ fn a_root_elsewhere_is_noted_and_xdg_is_honoured() {
|
||||
String::from_utf8_lossy(&out.stdout).contains("noted for next time"),
|
||||
"{out:?}"
|
||||
);
|
||||
assert_eq!(mode(&xdg.join("wanderbooks")), 0o700);
|
||||
assert_eq!(mode(&elsewhere), 0o700);
|
||||
let noted =
|
||||
std::fs::read_to_string(home.path().join(".local/share/wanderbooks.location")).unwrap();
|
||||
assert_eq!(
|
||||
noted.trim_end(),
|
||||
xdg.join("wanderbooks").display().to_string()
|
||||
);
|
||||
assert_eq!(noted.trim_end(), elsewhere.display().to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -122,3 +123,32 @@ fn setup_refuses_what_it_does_not_know_and_unsafe_folders() {
|
||||
let out = wanderbooks().args(["--force", "setup"]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(2));
|
||||
}
|
||||
|
||||
/// With another home given (tests), this account's XDG_DATA_HOME is never
|
||||
/// read: setup can't be aimed at this account's real books by it.
|
||||
#[test]
|
||||
fn another_home_ignores_this_accounts_data_home() {
|
||||
let home = tempfile::Builder::new()
|
||||
.prefix("wb-setup-xdg-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let elsewhere = home.path().join("elsewhere");
|
||||
std::fs::create_dir(&elsewhere).unwrap();
|
||||
let out = wanderbooks()
|
||||
.args(["setup", "--home"])
|
||||
.arg(home.path())
|
||||
.env("XDG_DATA_HOME", &elsewhere)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
assert!(
|
||||
home.path()
|
||||
.join(".local/share/wanderbooks/.wanderbooks-root")
|
||||
.is_file()
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_dir(&elsewhere).unwrap().count(),
|
||||
0,
|
||||
"XDG_DATA_HOME was used"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "ledger-host"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "wanderbooks: the books open while something holds them (the host, its leases, the helper and the agents' bridge)."
|
||||
|
||||
[dependencies]
|
||||
ledger-core = { path = "../ledger-core" }
|
||||
ledger-mcp = { path = "../ledger-mcp" }
|
||||
ledger-store = { path = "../ledger-store" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "io-std", "sync", "net", "signal", "time", "process"] }
|
||||
reqwest = "0.13"
|
||||
base64 = "0.22"
|
||||
tokio-util = { version = "0.7", features = ["rt"] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,940 @@
|
||||
//! The owner's commands: `wanderbooks serve`, `close` and `books` (plan
|
||||
//! 3.9, 5.5).
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: from a terminal the owner holds the books open, closes them, and
|
||||
//! makes, lists and adopts books, without ever naming or touching
|
||||
//! the books' private folder.
|
||||
//! SOURCE: plan/served.md 3.9 (a terminal running `wanderbooks serve`, and
|
||||
//! any command that needs the server, holds a lease; "Close the
|
||||
//! books" ends every lease), 3.1 (the CLI talks to admin.sock and
|
||||
//! never reads the books), 5.5 (adopt; preview and confirmation at
|
||||
//! the terminal).
|
||||
//! INVARIANTS:
|
||||
//! 1. A command that needs the server holds a lease while it runs, and
|
||||
//! lets go when it ends.
|
||||
//! 2. Nothing printed names the data root.
|
||||
//! 3. Adoption is previewed first and goes ahead only on a yes at the
|
||||
//! terminal, or --yes; a vault beside the books must be named (or
|
||||
//! --no-vault given): it is never silently left behind.
|
||||
//! ```
|
||||
|
||||
use crate::knobs::{FLAG, Knobs};
|
||||
use crate::lease::{Ask, HostMsg, Intent, Kind, Reason};
|
||||
use crate::opener::{HoldError, Lease, Opener, Starter};
|
||||
use ledger_mcp::admin::{self, Request, Response};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
/// How long a command waits for the books' host to answer one question.
|
||||
const HOST_WAIT: Duration = Duration::from_secs(5);
|
||||
/// How long a command waits for the books server to list the books.
|
||||
const LIST_WAIT: Duration = Duration::from_secs(30);
|
||||
/// How long `close` waits to see the books closed.
|
||||
const CLOSE_WAIT: Duration = Duration::from_secs(120);
|
||||
|
||||
/// Common flags: `--root DIR` and the test knobs.
|
||||
#[derive(Default)]
|
||||
struct Common {
|
||||
root: Option<PathBuf>,
|
||||
knobs: Knobs,
|
||||
}
|
||||
|
||||
impl Common {
|
||||
/// Take a common flag (`Ok(true)`), or leave the argument.
|
||||
fn take(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> {
|
||||
match arg {
|
||||
"--root" => {
|
||||
self.root = Some(PathBuf::from(args.next().ok_or("--root needs a folder")?));
|
||||
Ok(true)
|
||||
}
|
||||
FLAG => {
|
||||
self.knobs = Knobs::parse(&args.next().ok_or("--test-knobs needs a value")?)?;
|
||||
Ok(true)
|
||||
}
|
||||
_ => Ok(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn exit(code: i32) -> Box<dyn std::error::Error> {
|
||||
Box::new(crate::Exit(code))
|
||||
}
|
||||
|
||||
/// Hold the books and wait until they are open (INVARIANT 1).
|
||||
async fn open(opener: &mut Opener) -> Result<Lease, HoldError> {
|
||||
let deadline = tokio::time::Instant::now() + opener.knobs().open_wait;
|
||||
let too_long = || -> HoldError {
|
||||
"the books didn't open in time: run `wanderbooks doctor` to see why".into()
|
||||
};
|
||||
loop {
|
||||
let mut lease = tokio::time::timeout_at(deadline, opener.hold())
|
||||
.await
|
||||
.map_err(|_| too_long())??;
|
||||
loop {
|
||||
match tokio::time::timeout_at(deadline, lease.next()).await {
|
||||
Err(_) => return Err(too_long()),
|
||||
Ok(Some(HostMsg::Ready)) => {
|
||||
opener.opened();
|
||||
return Ok(lease);
|
||||
}
|
||||
Ok(Some(HostMsg::Ending {
|
||||
reason: Reason::ClosedByOwner,
|
||||
..
|
||||
})) => {
|
||||
return Err(HoldError::ClosedByOwner);
|
||||
}
|
||||
Ok(Some(HostMsg::Ending {
|
||||
reason: Reason::Restarting | Reason::Closing,
|
||||
..
|
||||
})) => break,
|
||||
Ok(Some(HostMsg::Ending { .. })) | Ok(None) => {
|
||||
// The wait before the next try counts against the
|
||||
// deadline too.
|
||||
tokio::time::timeout_at(deadline, opener.failed())
|
||||
.await
|
||||
.map_err(|_| too_long())??;
|
||||
break;
|
||||
}
|
||||
Ok(Some(_)) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the server's admin socket, holding the books open meanwhile.
|
||||
fn with_server(common: &Common, request: Request) -> Result<Response, String> {
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let outcome = rt.block_on(async {
|
||||
let mut opener = Opener::new(
|
||||
common.root.as_deref(),
|
||||
common.knobs,
|
||||
Starter::Direct,
|
||||
Kind::Cli,
|
||||
None,
|
||||
)?;
|
||||
let lease = open(&mut opener).await.map_err(|e| match e {
|
||||
HoldError::ClosedByOwner => {
|
||||
"the books were closed just now (Close the books): nothing was done".to_string()
|
||||
}
|
||||
HoldError::Failed(why) => why,
|
||||
})?;
|
||||
let socket = opener.run_dir().join(ledger_mcp::local::ADMIN_SOCKET);
|
||||
// A list is quick; a create or an adoption takes what it takes.
|
||||
let wait = matches!(request, Request::List).then_some(LIST_WAIT);
|
||||
let answer =
|
||||
tokio::task::spawn_blocking(move || admin::call_within(&socket, request, wait))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string());
|
||||
drop(lease);
|
||||
answer
|
||||
});
|
||||
// Past its deadline, the command ends: nothing pending holds it.
|
||||
rt.shutdown_timeout(Duration::from_secs(1));
|
||||
outcome
|
||||
}
|
||||
|
||||
/// Where the books stand while `close` waits for them to close.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum Closing {
|
||||
/// No host, and no server finishing: closed.
|
||||
Closed,
|
||||
/// A new host, opened by something since: closed, then opened again.
|
||||
Reopened,
|
||||
/// Still on their way (a server finishing, or a host not answering).
|
||||
Going,
|
||||
}
|
||||
|
||||
/// What the host's answer (`asked`) and the server's lock say.
|
||||
fn closing(
|
||||
asked: &Result<Option<HostMsg>, String>,
|
||||
server_lock_held: impl FnOnce() -> bool,
|
||||
) -> Closing {
|
||||
match asked {
|
||||
Ok(None) if server_lock_held() => Closing::Going,
|
||||
Ok(None) => Closing::Closed,
|
||||
Ok(Some(HostMsg::Leases { .. })) => Closing::Reopened,
|
||||
_ => Closing::Going,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `deadline` has passed at `now`.
|
||||
fn past(deadline: std::time::Instant, now: std::time::Instant) -> bool {
|
||||
now > deadline
|
||||
}
|
||||
|
||||
/// What `doctor` says of books no host holds.
|
||||
fn no_host(server_lock_held: bool) -> &'static str {
|
||||
if server_lock_held {
|
||||
"The books are closing: a books server is finishing its last call."
|
||||
} else {
|
||||
"The books are closed."
|
||||
}
|
||||
}
|
||||
|
||||
/// `wanderbooks serve`: hold the books open until Ctrl-C.
|
||||
pub fn serve(
|
||||
prog: &str,
|
||||
args: impl IntoIterator<Item = String>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut common = Common::default();
|
||||
let mut args = args.into_iter();
|
||||
while let Some(arg) = args.next() {
|
||||
if common.take(&arg, &mut args)? {
|
||||
continue;
|
||||
}
|
||||
match arg.as_str() {
|
||||
"--help" | "-h" => {
|
||||
eprintln!("usage: {prog}");
|
||||
eprintln!(" Opens the books and holds them open until Ctrl-C. They close 10 s");
|
||||
eprintln!(" after nothing else (the app, an agent session) holds them.");
|
||||
return Ok(());
|
||||
}
|
||||
other => {
|
||||
eprintln!("unknown argument: {other}");
|
||||
eprintln!("usage: {prog}");
|
||||
return Err(exit(2));
|
||||
}
|
||||
}
|
||||
}
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
let outcome = rt.block_on(async {
|
||||
let mut opener = Opener::new(
|
||||
common.root.as_deref(),
|
||||
common.knobs,
|
||||
Starter::Direct,
|
||||
Kind::Serve,
|
||||
None,
|
||||
)?;
|
||||
// Ctrl-C, SIGTERM and SIGHUP are heard from the start, whatever
|
||||
// serve is doing (opening, waiting to try again): none is missed.
|
||||
let stop = std::sync::Arc::new(tokio::sync::Notify::new());
|
||||
for kind in [
|
||||
tokio::signal::unix::SignalKind::interrupt(),
|
||||
tokio::signal::unix::SignalKind::terminate(),
|
||||
tokio::signal::unix::SignalKind::hangup(),
|
||||
] {
|
||||
let mut signals = tokio::signal::unix::signal(kind)?;
|
||||
let stop = stop.clone();
|
||||
tokio::spawn(async move {
|
||||
while signals.recv().await.is_some() {
|
||||
stop.notify_one();
|
||||
}
|
||||
});
|
||||
}
|
||||
let let_go = || {
|
||||
println!("Let go of the books.");
|
||||
Ok(())
|
||||
};
|
||||
let mut said_open = false;
|
||||
loop {
|
||||
println!("Opening the books…");
|
||||
let opened = tokio::select! {
|
||||
opened = open(&mut opener) => opened,
|
||||
() = stop.notified() => return let_go(),
|
||||
};
|
||||
let mut lease = match opened {
|
||||
Ok(lease) => lease,
|
||||
Err(HoldError::ClosedByOwner) => {
|
||||
println!("The books were closed (Close the books).");
|
||||
return Ok(());
|
||||
}
|
||||
Err(HoldError::Failed(why)) => {
|
||||
eprintln!("error: {why}");
|
||||
return Err(exit(1));
|
||||
}
|
||||
};
|
||||
if !said_open {
|
||||
println!(
|
||||
"The books are open. Press Ctrl-C to let go of them: they close when nothing else holds them."
|
||||
);
|
||||
said_open = true;
|
||||
} else {
|
||||
println!("The books are open again.");
|
||||
}
|
||||
let ended = tokio::select! {
|
||||
() = stop.notified() => None,
|
||||
msg = lease.next() => Some(msg),
|
||||
};
|
||||
match ended {
|
||||
None => {
|
||||
drop(lease);
|
||||
return let_go();
|
||||
}
|
||||
Some(Some(HostMsg::Ending {
|
||||
reason: Reason::ClosedByOwner,
|
||||
..
|
||||
})) => {
|
||||
println!("The books were closed (Close the books).");
|
||||
return Ok(());
|
||||
}
|
||||
Some(Some(HostMsg::Ending {
|
||||
reason: Reason::Restarting,
|
||||
..
|
||||
})) => {
|
||||
println!("The books are restarting (a new version)…");
|
||||
}
|
||||
Some(Some(HostMsg::Ending {
|
||||
reason: Reason::Closing,
|
||||
..
|
||||
})) => {}
|
||||
Some(_) => {
|
||||
println!("The books server stopped; opening the books again…");
|
||||
let waited = tokio::select! {
|
||||
waited = opener.failed() => waited,
|
||||
() = stop.notified() => return let_go(),
|
||||
};
|
||||
if let Err(why) = waited {
|
||||
eprintln!("error: {why}");
|
||||
return Err(exit(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
// A holder that gave up isn't held back by work still pending.
|
||||
rt.shutdown_timeout(Duration::from_secs(1));
|
||||
outcome
|
||||
}
|
||||
|
||||
/// `wanderbooks close`: close the books now; every holder lets go.
|
||||
pub fn close(
|
||||
prog: &str,
|
||||
args: impl IntoIterator<Item = String>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut common = Common::default();
|
||||
let mut args = args.into_iter();
|
||||
while let Some(arg) = args.next() {
|
||||
if common.take(&arg, &mut args)? {
|
||||
continue;
|
||||
}
|
||||
match arg.as_str() {
|
||||
"--help" | "-h" => {
|
||||
eprintln!("usage: {prog}");
|
||||
eprintln!(" Closes the books now: the app, agent sessions and `serve` let go.");
|
||||
return Ok(());
|
||||
}
|
||||
other => {
|
||||
eprintln!("unknown argument: {other}");
|
||||
eprintln!("usage: {prog}");
|
||||
return Err(exit(2));
|
||||
}
|
||||
}
|
||||
}
|
||||
let root = ledger_store::root::Root::find(common.root.as_deref()).map_err(|e| e.to_string())?;
|
||||
let run = root.run_dir().map_err(|e| e.to_string())?;
|
||||
let asked = crate::opener::ask_once_within(&run, Ask::Intent(Intent::Close), HOST_WAIT)
|
||||
.map_err(|why| {
|
||||
// Asked, and no answer: the host may yet close them.
|
||||
eprintln!(
|
||||
"error: {why}: the books may still close; `wanderbooks doctor` shows whether they did"
|
||||
);
|
||||
exit(1)
|
||||
})?;
|
||||
match asked {
|
||||
None => {
|
||||
println!("The books are already closed.");
|
||||
Ok(())
|
||||
}
|
||||
Some(HostMsg::Done { .. }) => {
|
||||
println!("Closing the books: everything holding them lets go…");
|
||||
let deadline = std::time::Instant::now() + CLOSE_WAIT;
|
||||
loop {
|
||||
let asked = crate::opener::ask_once_within(&run, Ask::Leases, HOST_WAIT);
|
||||
match closing(&asked, || crate::opener::server_lock_held(&run)) {
|
||||
Closing::Closed => break,
|
||||
Closing::Reopened => {
|
||||
println!("The books were closed; something has opened them again.");
|
||||
return Ok(());
|
||||
}
|
||||
Closing::Going => {}
|
||||
}
|
||||
if past(deadline, std::time::Instant::now()) {
|
||||
println!("They are still closing (a request is finishing).");
|
||||
return Ok(());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
println!("The books are closed.");
|
||||
Ok(())
|
||||
}
|
||||
Some(HostMsg::Refused(why)) => {
|
||||
eprintln!("error: {why}");
|
||||
Err(exit(1))
|
||||
}
|
||||
Some(other) => {
|
||||
eprintln!("error: the books' host answered oddly: {other:?}");
|
||||
Err(exit(1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn books_usage(prog: &str) {
|
||||
eprintln!("usage: {prog} list");
|
||||
eprintln!(" {prog} create ID [--currency CODE]");
|
||||
eprintln!(
|
||||
" {prog} adopt DATA-DIR --id ID [--toml FILE] [--config-json FILE] [--vault DIR --vault-key FILE | --no-vault] [--skip-unknown] [--yes]"
|
||||
);
|
||||
eprintln!(" list the books, and whether each is open");
|
||||
eprintln!(" create start new, empty books (base currency USD unless given)");
|
||||
eprintln!(" adopt copy existing books in, verified; the original is left as it was");
|
||||
}
|
||||
|
||||
/// `wanderbooks books list|create|adopt`.
|
||||
pub fn books(
|
||||
prog: &str,
|
||||
args: impl IntoIterator<Item = String>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut common = Common::default();
|
||||
let mut words: Vec<String> = Vec::new();
|
||||
let mut currency = "USD".to_string();
|
||||
let mut id: Option<String> = None;
|
||||
let mut toml: Option<PathBuf> = None;
|
||||
let mut config_json: Option<PathBuf> = None;
|
||||
let mut vault: Option<PathBuf> = None;
|
||||
let mut vault_key: Option<PathBuf> = None;
|
||||
let mut no_vault = false;
|
||||
let mut skip_unknown = false;
|
||||
let mut yes = false;
|
||||
let mut args = args.into_iter();
|
||||
while let Some(arg) = args.next() {
|
||||
if common.take(&arg, &mut args)? {
|
||||
continue;
|
||||
}
|
||||
let mut value = |what: &str| args.next().ok_or_else(|| format!("{arg} needs {what}"));
|
||||
match arg.as_str() {
|
||||
"--currency" => currency = value("a currency code")?,
|
||||
"--id" => id = Some(value("an id")?),
|
||||
"--toml" => toml = Some(PathBuf::from(value("a file")?)),
|
||||
"--config-json" => config_json = Some(PathBuf::from(value("a file")?)),
|
||||
"--vault" => vault = Some(PathBuf::from(value("a folder")?)),
|
||||
"--vault-key" => vault_key = Some(PathBuf::from(value("a file")?)),
|
||||
"--no-vault" => no_vault = true,
|
||||
"--skip-unknown" => skip_unknown = true,
|
||||
"--yes" => yes = true,
|
||||
"--help" | "-h" => {
|
||||
books_usage(prog);
|
||||
return Ok(());
|
||||
}
|
||||
s if s.starts_with('-') => {
|
||||
eprintln!("unknown flag: {s}");
|
||||
books_usage(prog);
|
||||
return Err(exit(2));
|
||||
}
|
||||
_ => words.push(arg),
|
||||
}
|
||||
}
|
||||
let words: Vec<&str> = words.iter().map(String::as_str).collect();
|
||||
let answer = match words.as_slice() {
|
||||
["list"] => with_server(&common, Request::List),
|
||||
["create", new] => with_server(
|
||||
&common,
|
||||
Request::Create {
|
||||
id: (*new).to_string(),
|
||||
currency: currency.clone(),
|
||||
},
|
||||
),
|
||||
["adopt", source] => {
|
||||
let Some(id) = id else {
|
||||
eprintln!("error: name the adopted books with --id ID");
|
||||
return Err(exit(2));
|
||||
};
|
||||
return adopt(
|
||||
&common,
|
||||
Path::new(source),
|
||||
id,
|
||||
AdoptFiles {
|
||||
toml,
|
||||
config_json,
|
||||
vault,
|
||||
vault_key,
|
||||
no_vault,
|
||||
},
|
||||
skip_unknown,
|
||||
yes,
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
books_usage(prog);
|
||||
return Err(exit(2));
|
||||
}
|
||||
};
|
||||
match answer {
|
||||
Ok(Response::Books(books)) if books.is_empty() => {
|
||||
println!("No books yet: `wanderbooks books create ID` starts some.");
|
||||
Ok(())
|
||||
}
|
||||
Ok(Response::Books(books)) => {
|
||||
for b in books {
|
||||
match b.problem {
|
||||
None => println!("{} open", b.id),
|
||||
Some(why) => println!("{} didn't open: {why}", b.id),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Ok(Response::Created { id, problem: None }) => {
|
||||
println!("Made new books \"{id}\" ({currency}).");
|
||||
Ok(())
|
||||
}
|
||||
Ok(Response::Created {
|
||||
id,
|
||||
problem: Some(why),
|
||||
}) => {
|
||||
eprintln!(
|
||||
"error: new books \"{id}\" were made but didn't open: {why}. Closing the books and opening them again tries again."
|
||||
);
|
||||
Err(exit(1))
|
||||
}
|
||||
Ok(Response::Refused(why)) | Err(why) => {
|
||||
eprintln!("error: {why}");
|
||||
Err(exit(1))
|
||||
}
|
||||
Ok(other) => {
|
||||
eprintln!("error: the books server answered oddly: {other:?}");
|
||||
Err(exit(1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The files an adoption brings in with the books.
|
||||
struct AdoptFiles {
|
||||
toml: Option<PathBuf>,
|
||||
config_json: Option<PathBuf>,
|
||||
vault: Option<PathBuf>,
|
||||
vault_key: Option<PathBuf>,
|
||||
no_vault: bool,
|
||||
}
|
||||
|
||||
/// A hash's first twelve characters, for people.
|
||||
fn short_hash(hash: &str) -> String {
|
||||
hash.chars().take(12).collect()
|
||||
}
|
||||
|
||||
/// Where a P1 books dir keeps its vault: `vault/` beside the data dir.
|
||||
fn vault_beside(source: &Path) -> Option<PathBuf> {
|
||||
let source = std::fs::canonicalize(source).ok()?;
|
||||
let vault = source.parent()?.join("vault");
|
||||
vault.is_dir().then_some(vault)
|
||||
}
|
||||
|
||||
/// Whether standard input is a terminal.
|
||||
fn at_a_terminal() -> bool {
|
||||
// SAFETY: isatty has no preconditions.
|
||||
unsafe { libc::isatty(0) == 1 }
|
||||
}
|
||||
|
||||
/// `wanderbooks books adopt` (INVARIANT 3).
|
||||
fn adopt(
|
||||
common: &Common,
|
||||
source: &Path,
|
||||
id: String,
|
||||
files: AdoptFiles,
|
||||
skip_unknown: bool,
|
||||
yes: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let fail = |why: String| -> Result<(), Box<dyn std::error::Error>> {
|
||||
eprintln!("error: {why}");
|
||||
Err(exit(1))
|
||||
};
|
||||
let shown = source.display().to_string();
|
||||
if files.no_vault && (files.vault.is_some() || files.vault_key.is_some()) {
|
||||
return fail("--no-vault and --vault can't both be given".into());
|
||||
}
|
||||
match (&files.vault, &files.vault_key) {
|
||||
(Some(_), None) => {
|
||||
return fail("give the vault's key too, with --vault-key FILE (often ~/.config/wanderbooks/vault.key)".into());
|
||||
}
|
||||
(None, Some(_)) => return fail("give the vault's folder too, with --vault DIR".into()),
|
||||
_ => {}
|
||||
}
|
||||
if files.vault.is_none()
|
||||
&& !files.no_vault
|
||||
&& let Some(beside) = vault_beside(source)
|
||||
{
|
||||
return fail(format!(
|
||||
"there is a TIN vault beside these books ({}): name it with --vault DIR and its key with --vault-key FILE, or give --no-vault to leave it behind",
|
||||
beside.display()
|
||||
));
|
||||
}
|
||||
let read = |path: &Option<PathBuf>| -> Result<Option<String>, String> {
|
||||
path.as_ref()
|
||||
.map(|p| {
|
||||
std::fs::read_to_string(p).map_err(|e| format!("can't read {}: {e}", p.display()))
|
||||
})
|
||||
.transpose()
|
||||
};
|
||||
let settings_toml = match read(&files.toml) {
|
||||
Ok(t) => t,
|
||||
Err(why) => return fail(why),
|
||||
};
|
||||
let config_json = match read(&files.config_json) {
|
||||
Ok(t) => t,
|
||||
Err(why) => return fail(why),
|
||||
};
|
||||
let source = std::path::absolute(source).unwrap_or_else(|_| source.to_path_buf());
|
||||
let request = |expect_head: Option<(u64, String)>| Request::Adopt {
|
||||
id: id.clone(),
|
||||
source: source.clone(),
|
||||
settings_toml: settings_toml.clone(),
|
||||
config_json: config_json.clone(),
|
||||
vault_dir: files.vault.clone(),
|
||||
vault_key: files.vault_key.clone(),
|
||||
confirmed: expect_head.is_some(),
|
||||
expect_head,
|
||||
skip_unknown,
|
||||
};
|
||||
let preview = match with_server(common, request(None)) {
|
||||
Ok(Response::Preview(p)) => p,
|
||||
Ok(Response::Refused(why)) | Err(why) => return fail(format!("{shown}: {why}")),
|
||||
Ok(other) => return fail(format!("the books server answered oddly: {other:?}")),
|
||||
};
|
||||
println!(
|
||||
"Adopting the books at {} as \"{id}\": through event {} ({}).",
|
||||
source.display(),
|
||||
preview.source_head.0,
|
||||
short_hash(&preview.source_head.1)
|
||||
);
|
||||
if preview.keeps_settings {
|
||||
println!("They keep their own settings.");
|
||||
if files.toml.is_some() {
|
||||
println!("(The settings file given isn't used.)");
|
||||
}
|
||||
} else if preview.settings.is_empty() {
|
||||
println!("No settings move in.");
|
||||
} else {
|
||||
println!("These settings move in from the settings file:");
|
||||
for s in &preview.settings {
|
||||
match &s.value {
|
||||
Some(v) => println!(" {} = {v}", s.key),
|
||||
None => println!(" {} (cleared)", s.key),
|
||||
}
|
||||
}
|
||||
}
|
||||
for n in &preview.notes {
|
||||
println!(" note: {n}");
|
||||
}
|
||||
for u in &preview.unknown {
|
||||
println!(" not moved in (the books don't know it): {u}");
|
||||
}
|
||||
if !preview.comments.is_empty() {
|
||||
println!(
|
||||
"The settings file's comments (not settings: keep what matters as a convention or a decision):"
|
||||
);
|
||||
for c in &preview.comments {
|
||||
println!(" # {c}");
|
||||
}
|
||||
}
|
||||
match &files.vault {
|
||||
Some(v) => println!(
|
||||
"The TIN vault at {} is copied in with its key ({} records, each checked to open).",
|
||||
v.display(),
|
||||
preview.vault_records
|
||||
),
|
||||
None => println!("No TIN vault is copied."),
|
||||
}
|
||||
if !preview.unknown.is_empty() && !skip_unknown {
|
||||
return fail(
|
||||
"the settings file has entries the books don't know (above): take them out, or adopt with --skip-unknown to leave them behind".into(),
|
||||
);
|
||||
}
|
||||
println!("The original is left exactly as it is.");
|
||||
if !yes {
|
||||
if !at_a_terminal() {
|
||||
return fail("not adopted: confirm at a terminal, or run again with --yes".into());
|
||||
}
|
||||
print!("Adopt them? [y/N] ");
|
||||
use std::io::Write;
|
||||
std::io::stdout().flush()?;
|
||||
let mut answer = String::new();
|
||||
std::io::stdin().read_line(&mut answer)?;
|
||||
if !matches!(answer.trim(), "y" | "Y" | "yes" | "Yes") {
|
||||
println!("Not adopted.");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
match with_server(common, request(Some(preview.source_head.clone()))) {
|
||||
Ok(Response::Adopted(a)) => {
|
||||
for line in adopted_lines(&a) {
|
||||
println!("{line}");
|
||||
}
|
||||
if let Some(why) = a.problem {
|
||||
return fail(format!(
|
||||
"the adopted books didn't open: {why}. Closing the books and opening them again tries again."
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Ok(Response::Refused(why)) | Err(why) => fail(format!("{shown}: {why}")),
|
||||
Ok(other) => fail(format!("the books server answered oddly: {other:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// What an adoption did, a line each.
|
||||
fn adopted_lines(a: &admin::Adopted) -> Vec<String> {
|
||||
let mut extra = String::new();
|
||||
if a.unnamed_blobs > 0 {
|
||||
extra.push_str(&format!(
|
||||
", {} the books don't name (kept)",
|
||||
a.unnamed_blobs
|
||||
));
|
||||
}
|
||||
if a.skipped_files > 0 {
|
||||
extra.push_str(&format!(
|
||||
", {} unfinished or hidden files left behind",
|
||||
a.skipped_files
|
||||
));
|
||||
}
|
||||
let mut lines = vec![format!(
|
||||
"Adopted as \"{}\": {} stored files copied, {} of them checked against the books{extra}.",
|
||||
a.id, a.blob_files, a.blobs_verified
|
||||
)];
|
||||
// A vault is adopted only with its key, so a copied key says a vault
|
||||
// came too (with its records, perhaps none).
|
||||
if a.key_copied {
|
||||
lines.push(format!("TIN vault: {} records and its key.", a.vault_files));
|
||||
}
|
||||
if a.settings_imported > 0 {
|
||||
lines.push(format!("{} settings moved in.", a.settings_imported));
|
||||
}
|
||||
lines.push(format!(
|
||||
"Head: event {} ({}).",
|
||||
a.head.0,
|
||||
short_hash(&a.head.1)
|
||||
));
|
||||
lines
|
||||
}
|
||||
|
||||
/// How long `doctor` waits for the books server to answer.
|
||||
const DOCTOR_WAIT: Duration = Duration::from_secs(5);
|
||||
/// How many of each log's last lines `doctor` shows.
|
||||
const DOCTOR_LINES: usize = 12;
|
||||
|
||||
/// The last `n` lines of a log in `run/`, with the root's path taken out.
|
||||
fn log_tail(run: &Path, name: &str, root: &Path, n: usize) -> Vec<String> {
|
||||
let Ok(text) = std::fs::read_to_string(run.join(name)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
// The root as found, and as it really is (through any link).
|
||||
let real = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
|
||||
let spellings = [root.display().to_string(), real.display().to_string()];
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
lines[lines.len().saturating_sub(n)..]
|
||||
.iter()
|
||||
.map(|l| {
|
||||
let l = spellings.iter().fold(l.to_string(), |l, s| {
|
||||
l.replace(s.as_str(), ledger_store::root::THE_ROOT)
|
||||
});
|
||||
// A log line can carry a panic's text: no full TIN is shown.
|
||||
ledger_core::parties::redact_full_tins(&l).into_owned()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `wanderbooks doctor`: whether the books are open, who holds them, and
|
||||
/// what the host and the server last said. It names no folder.
|
||||
pub fn doctor(
|
||||
prog: &str,
|
||||
args: impl IntoIterator<Item = String>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut common = Common::default();
|
||||
let mut args = args.into_iter();
|
||||
while let Some(arg) = args.next() {
|
||||
if common.take(&arg, &mut args)? {
|
||||
continue;
|
||||
}
|
||||
match arg.as_str() {
|
||||
"--help" | "-h" => {
|
||||
eprintln!("usage: {prog}");
|
||||
eprintln!(
|
||||
" Says whether the books are open, what holds them, and what went wrong."
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
other => {
|
||||
eprintln!("unknown argument: {other}");
|
||||
eprintln!("usage: {prog}");
|
||||
return Err(exit(2));
|
||||
}
|
||||
}
|
||||
}
|
||||
let root = match ledger_store::root::Root::find(common.root.as_deref()) {
|
||||
Ok(root) => root,
|
||||
Err(e) => {
|
||||
println!("The books' private folder: {e}");
|
||||
return Err(exit(1));
|
||||
}
|
||||
};
|
||||
println!("The books' private folder: there, and only your account can open it.");
|
||||
println!("Isolation: same_user (advisory): hidden from agents, not locked against them.");
|
||||
let run = root.run_dir().map_err(|e| e.to_string())?;
|
||||
match crate::opener::ask_once_within(&run, Ask::Leases, DOCTOR_WAIT) {
|
||||
Ok(None) => println!("{}", no_host(crate::opener::server_lock_held(&run))),
|
||||
Ok(Some(HostMsg::Leases { ready, leases })) => {
|
||||
if ready {
|
||||
println!("The books are open.");
|
||||
// Each book, and why one didn't open.
|
||||
let socket = run.join(ledger_mcp::local::ADMIN_SOCKET);
|
||||
// A quick look: a wedged server doesn't hang the doctor.
|
||||
let listed = admin::call_within(&socket, Request::List, Some(DOCTOR_WAIT));
|
||||
if let Err(why) = &listed {
|
||||
println!(" The books server doesn't answer: {why}");
|
||||
}
|
||||
if let Ok(admin::Response::Books(books)) = listed {
|
||||
for b in books {
|
||||
match b.problem {
|
||||
None => println!(" {} open", b.id),
|
||||
Some(why) => println!(" {} didn't open: {why}", b.id),
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("The books are opening (the books server isn't ready yet).");
|
||||
}
|
||||
if leases.is_empty() {
|
||||
println!("Nothing holds them: they close soon.");
|
||||
} else {
|
||||
println!("Held by:");
|
||||
for l in leases {
|
||||
match l.book {
|
||||
Some(book) => println!(" {} (pid {}), on {book}", l.kind.label(), l.pid),
|
||||
None => println!(" {} (pid {})", l.kind.label(), l.pid),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some(HostMsg::Ending { .. })) => println!("The books are closing."),
|
||||
Ok(Some(other)) => println!("The books' host answered oddly: {other:?}"),
|
||||
Err(why) => println!("The books' host can't be asked: {why}"),
|
||||
}
|
||||
for (name, what) in [
|
||||
(crate::start::HOST_LOG, "The host"),
|
||||
(crate::host::SERVER_LOG, "The books server"),
|
||||
] {
|
||||
let tail = log_tail(&run, name, root.path(), DOCTOR_LINES);
|
||||
if !tail.is_empty() {
|
||||
println!("{what}, last said:");
|
||||
for line in tail {
|
||||
println!(" {line}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn close_reads_the_books_state_for_what_it_is() {
|
||||
let leases = Ok(Some(HostMsg::Leases {
|
||||
ready: true,
|
||||
leases: vec![],
|
||||
}));
|
||||
assert_eq!(closing(&Ok(None), || false), Closing::Closed);
|
||||
assert_eq!(
|
||||
closing(&Ok(None), || true),
|
||||
Closing::Going,
|
||||
"a server finishing"
|
||||
);
|
||||
assert_eq!(closing(&leases, || false), Closing::Reopened);
|
||||
assert_eq!(closing(&Err("no".into()), || false), Closing::Going);
|
||||
assert_eq!(closing(&Ok(Some(HostMsg::Ready)), || false), Closing::Going);
|
||||
let now = std::time::Instant::now();
|
||||
let later = now + Duration::from_secs(1);
|
||||
assert!(!past(later, now));
|
||||
assert!(!past(now, now), "not yet at the deadline itself");
|
||||
assert!(past(now, later));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doctor_tells_closing_from_closed() {
|
||||
assert!(no_host(true).contains("closing"));
|
||||
assert_eq!(no_host(false), "The books are closed.");
|
||||
}
|
||||
|
||||
fn adopted() -> admin::Adopted {
|
||||
admin::Adopted {
|
||||
id: "old".into(),
|
||||
source_head: (3, "a".repeat(64)),
|
||||
head: (4, "0123456789abcdef".repeat(4)),
|
||||
blob_files: 5,
|
||||
blobs_verified: 4,
|
||||
unnamed_blobs: 0,
|
||||
skipped_files: 0,
|
||||
vault_files: 0,
|
||||
key_copied: false,
|
||||
settings_imported: 0,
|
||||
settings_ignored: false,
|
||||
source_lock_created: false,
|
||||
problem: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_adoption_says_what_it_did_and_only_that() {
|
||||
let plain = adopted_lines(&adopted());
|
||||
assert_eq!(
|
||||
plain,
|
||||
[
|
||||
"Adopted as \"old\": 5 stored files copied, 4 of them checked against the books.",
|
||||
"Head: event 4 (0123456789ab).",
|
||||
]
|
||||
);
|
||||
let all = adopted_lines(&admin::Adopted {
|
||||
unnamed_blobs: 1,
|
||||
skipped_files: 2,
|
||||
vault_files: 3,
|
||||
key_copied: true,
|
||||
settings_imported: 6,
|
||||
..adopted()
|
||||
});
|
||||
assert_eq!(
|
||||
all,
|
||||
[
|
||||
"Adopted as \"old\": 5 stored files copied, 4 of them checked against the books, 1 the books don't name (kept), 2 unfinished or hidden files left behind.",
|
||||
"TIN vault: 3 records and its key.",
|
||||
"6 settings moved in.",
|
||||
"Head: event 4 (0123456789ab).",
|
||||
]
|
||||
);
|
||||
// A vault with no records still came, with its key.
|
||||
let empty = adopted_lines(&admin::Adopted {
|
||||
key_copied: true,
|
||||
..adopted()
|
||||
});
|
||||
assert!(empty.contains(&"TIN vault: 0 records and its key.".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_log_lines_shown_name_no_folder_and_no_tin() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path().join("root");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
let run = root.join("run");
|
||||
std::fs::create_dir(&run).unwrap();
|
||||
std::fs::write(
|
||||
run.join("server.log"),
|
||||
format!(
|
||||
"old line\nthread panicked at {}/books/acme: mid-write 241-40-4915\nlast\n",
|
||||
root.display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let tail = log_tail(&run, "server.log", &root, 2);
|
||||
assert_eq!(tail.len(), 2);
|
||||
let text = tail.join("\n");
|
||||
assert!(!text.contains(&root.display().to_string()), "{text}");
|
||||
assert!(!text.contains("241-40-4915"), "{text}");
|
||||
assert!(text.contains(ledger_store::root::THE_ROOT), "{text}");
|
||||
assert_eq!(tail[1], "last");
|
||||
assert!(log_tail(&run, "none.log", &root, 5).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
//! `wanderbooks helper`: the small unconfined process a confined mode
|
||||
//! keeps beside it (plan 3.9, "Starting the host cleanly").
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: a mode that confines itself (the viewer, the bridge) can still
|
||||
//! start the host cleanly and hand off the few things a sandbox
|
||||
//! forbids, through a channel that can ask for nothing else.
|
||||
//! SOURCE: plan/served.md 3.9 (the helper: spawned over an inherited
|
||||
//! socket pair before any thread, a fixed message set; it never
|
||||
//! accepts a path or a command line).
|
||||
//! INVARIANTS:
|
||||
//! 1. It talks only over the socket it was handed (descriptor 3), one
|
||||
//! JSON line each way, and exits when that closes.
|
||||
//! 2. It accepts exactly start_host, open_with (an attachment id),
|
||||
//! stage_backup and start_updater, none carrying a path or command;
|
||||
//! anything else is refused.
|
||||
//! 3. start_host starts the host cleanly (start.rs).
|
||||
//! 4. Spawned before the caller starts any thread.
|
||||
//! 5. A message is exactly its version and one ask, on a line of at
|
||||
//! most MAX_LINE bytes; anything else ends the helper.
|
||||
//! ```
|
||||
|
||||
use crate::knobs::{FLAG, Knobs};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::{BufRead, Write};
|
||||
use std::os::fd::{AsRawFd, FromRawFd};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// This protocol's version.
|
||||
pub const PROTO: u32 = 1;
|
||||
/// The descriptor the helper is handed its socket on.
|
||||
const FD: i32 = 3;
|
||||
/// The longest line either side sends.
|
||||
const MAX_LINE: u64 = 64 * 1024;
|
||||
|
||||
/// A message to the helper (INVARIANT 2).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Message {
|
||||
/// The sender's protocol version.
|
||||
pub proto: u32,
|
||||
/// What it asks.
|
||||
#[serde(flatten)]
|
||||
pub ask: HelperAsk,
|
||||
}
|
||||
|
||||
/// What the helper can be asked: exactly these (INVARIANT 2).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum HelperAsk {
|
||||
/// Start the books' host.
|
||||
StartHost,
|
||||
/// Open a document with the system's app for it.
|
||||
OpenWith {
|
||||
/// The attachment's id.
|
||||
attachment: u64,
|
||||
},
|
||||
/// Ask the owner for a backup file and stage it for a restore.
|
||||
StageBackup,
|
||||
/// Start the updater.
|
||||
StartUpdater,
|
||||
}
|
||||
|
||||
/// The helper's answer.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum Answer {
|
||||
/// Done.
|
||||
Done,
|
||||
/// Refused, with why.
|
||||
Refused(String),
|
||||
}
|
||||
|
||||
/// The caller's end: a running helper.
|
||||
pub struct Client {
|
||||
stream: std::sync::Mutex<UnixStream>,
|
||||
child: std::sync::Mutex<std::process::Child>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Spawn a helper for the root (`root`, else this account's), with
|
||||
/// these knobs. Call it before starting any thread (INVARIANT 4).
|
||||
pub fn spawn(root: Option<&Path>, knobs: &Knobs) -> Result<Self, String> {
|
||||
let (ours, theirs) = UnixStream::pair().map_err(|e| e.to_string())?;
|
||||
let exe = crate::start::this_executable()?;
|
||||
let mut c = std::process::Command::new(exe);
|
||||
c.arg("helper");
|
||||
if let Some(root) = root {
|
||||
let found = ledger_store::root::Root::find(Some(root)).map_err(|e| e.to_string())?;
|
||||
c.arg("--root").arg(found.path());
|
||||
}
|
||||
c.args(knobs.args())
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null());
|
||||
let fd = theirs.as_raw_fd();
|
||||
// SAFETY: dup2 and fcntl are async-signal-safe; they give the child
|
||||
// its end of the pair as descriptor 3, without close-on-exec (which
|
||||
// a descriptor already numbered 3 would still have).
|
||||
unsafe {
|
||||
c.pre_exec(move || {
|
||||
let ok =
|
||||
(fd == FD || libc::dup2(fd, FD) >= 0) && libc::fcntl(FD, libc::F_SETFD, 0) >= 0;
|
||||
if !ok {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
let child = c
|
||||
.spawn()
|
||||
.map_err(|e| format!("can't start the helper: {e}"))?;
|
||||
drop(theirs);
|
||||
Ok(Self {
|
||||
stream: std::sync::Mutex::new(ours),
|
||||
child: std::sync::Mutex::new(child),
|
||||
})
|
||||
}
|
||||
|
||||
/// Ask the helper one thing (blocking).
|
||||
pub fn ask(&self, ask: HelperAsk) -> Result<Answer, String> {
|
||||
let stream = self.stream.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut line =
|
||||
serde_json::to_string(&Message { proto: PROTO, ask }).map_err(|e| e.to_string())?;
|
||||
line.push('\n');
|
||||
(&*stream)
|
||||
.write_all(line.as_bytes())
|
||||
.map_err(|e| format!("the helper is gone: {}", e.kind()))?;
|
||||
let mut answer = String::new();
|
||||
std::io::BufReader::new(std::io::Read::take(&*stream, MAX_LINE))
|
||||
.read_line(&mut answer)
|
||||
.map_err(|e| format!("the helper didn't answer: {}", e.kind()))?;
|
||||
serde_json::from_str(answer.trim_end()).map_err(|_| "the helper is gone".to_string())
|
||||
}
|
||||
|
||||
/// Start the books' host (INVARIANT 3).
|
||||
pub fn start_host(&self) -> Result<(), String> {
|
||||
match self.ask(HelperAsk::StartHost)? {
|
||||
Answer::Done => Ok(()),
|
||||
Answer::Refused(why) => Err(why),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Client {
|
||||
fn drop(&mut self) {
|
||||
// Closing our end ends the helper (INVARIANT 1); reap it.
|
||||
let stream = self.stream.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _ = stream.shutdown(std::net::Shutdown::Both);
|
||||
drop(stream);
|
||||
let _ = self.child.lock().unwrap_or_else(|e| e.into_inner()).wait();
|
||||
}
|
||||
}
|
||||
|
||||
/// What the helper does with one message.
|
||||
fn answer(ask: HelperAsk, root: Option<&Path>, knobs: &Knobs) -> Answer {
|
||||
match ask {
|
||||
HelperAsk::StartHost => match crate::start::start_host(root, knobs) {
|
||||
Ok(()) => Answer::Done,
|
||||
Err(why) => Answer::Refused(why),
|
||||
},
|
||||
HelperAsk::OpenWith { .. } => {
|
||||
Answer::Refused("opening documents comes with the viewer's menu".into())
|
||||
}
|
||||
HelperAsk::StageBackup => Answer::Refused("restoring isn't built yet".into()),
|
||||
HelperAsk::StartUpdater => Answer::Refused("updates come with the installers".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `wanderbooks helper`: serve the socket on descriptor 3 until it closes
|
||||
/// (INVARIANT 1).
|
||||
pub fn run(
|
||||
prog: &str,
|
||||
args: impl IntoIterator<Item = String>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut root: Option<PathBuf> = None;
|
||||
let mut knobs = Knobs::default();
|
||||
let mut args = args.into_iter();
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--root" => root = Some(PathBuf::from(args.next().ok_or("--root needs a folder")?)),
|
||||
FLAG => knobs = Knobs::parse(&args.next().ok_or("--test-knobs needs a value")?)?,
|
||||
"--help" | "-h" => {
|
||||
eprintln!("usage: {prog} [--root DIR]");
|
||||
eprintln!(" Started by a confined part of wanderbooks, over descriptor 3.");
|
||||
return Ok(());
|
||||
}
|
||||
other => {
|
||||
eprintln!("unknown argument: {other}");
|
||||
return Err(Box::new(crate::Exit(2)));
|
||||
}
|
||||
}
|
||||
}
|
||||
// SAFETY: descriptor 3 is ours by INVARIANT 1; if it isn't a socket,
|
||||
// the peer check below refuses it.
|
||||
let stream = unsafe { UnixStream::from_raw_fd(FD) };
|
||||
// Taken: nothing this helper starts inherits it.
|
||||
// SAFETY: fcntl on our own descriptor.
|
||||
unsafe { libc::fcntl(FD, libc::F_SETFD, libc::FD_CLOEXEC) };
|
||||
if !ledger_mcp::local::peer_is_us(&stream) {
|
||||
eprintln!("{prog}: descriptor 3 isn't a socket to this account's own process");
|
||||
return Err(Box::new(crate::Exit(2)));
|
||||
}
|
||||
serve(std::io::BufReader::new(&stream), &stream, |ask| {
|
||||
answer(ask, root.as_deref(), &knobs)
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Answer each message read from `reader` on `writer`, a line each, with
|
||||
/// `reply` for the asks of this version, until the input ends or a line
|
||||
/// is too long or cut off (INVARIANTS 1, 5).
|
||||
fn serve<R: BufRead, W: Write>(mut reader: R, mut writer: W, reply: impl Fn(HelperAsk) -> Answer) {
|
||||
loop {
|
||||
// INVARIANT 5: never more than a line's worth read.
|
||||
let mut line = String::new();
|
||||
match std::io::Read::take(&mut reader, MAX_LINE + 1).read_line(&mut line) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(_) if !line.ends_with('\n') => break,
|
||||
Ok(_) => {}
|
||||
}
|
||||
let parsed = crate::lease::strict_pair(line.trim_end())
|
||||
.and_then(|v| serde_json::from_value::<Message>(v).map_err(|e| e.to_string()));
|
||||
let answer = match parsed {
|
||||
Ok(m) if m.proto == PROTO => reply(m.ask),
|
||||
Ok(_) => Answer::Refused("the helper is another version: restart".into()),
|
||||
Err(e) => Answer::Refused(format!("not understood: {e}")),
|
||||
};
|
||||
let mut text = serde_json::to_string(&answer).unwrap_or_default();
|
||||
text.push('\n');
|
||||
if writer.write_all(text.as_bytes()).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn exactly_four_asks_and_none_carries_a_path() {
|
||||
for ok in [
|
||||
json!({"proto": 1, "start_host": null}),
|
||||
json!({"proto": 1, "open_with": {"attachment": 7}}),
|
||||
json!({"proto": 1, "stage_backup": null}),
|
||||
json!({"proto": 1, "start_updater": null}),
|
||||
] {
|
||||
assert!(
|
||||
serde_json::from_value::<Message>(ok.clone()).is_ok(),
|
||||
"{ok}"
|
||||
);
|
||||
}
|
||||
for bad in [
|
||||
json!({"proto": 1, "open_with": {"attachment": 7, "path": "/etc/passwd"}}),
|
||||
json!({"proto": 1, "open_with": {"path": "/x"}}),
|
||||
json!({"proto": 1, "run": {"command": "sh"}}),
|
||||
json!({"proto": 1, "start_host": {"root": "/x"}}),
|
||||
json!({"proto": 1}),
|
||||
] {
|
||||
assert!(
|
||||
serde_json::from_value::<Message>(bad.clone()).is_err(),
|
||||
"{bad}"
|
||||
);
|
||||
}
|
||||
// Two asks in one message: refused, not half-read.
|
||||
let two = r#"{"proto":1,"start_host":null,"start_updater":null}"#;
|
||||
assert!(crate::lease::strict_pair(two).is_err());
|
||||
}
|
||||
|
||||
/// Serve `input`; the answers, a line each.
|
||||
fn served(input: &[u8]) -> Vec<String> {
|
||||
let mut out: Vec<u8> = Vec::new();
|
||||
serve(input, &mut out, |ask| match ask {
|
||||
HelperAsk::StartHost => Answer::Done,
|
||||
_ => Answer::Refused("no".into()),
|
||||
});
|
||||
String::from_utf8(out)
|
||||
.unwrap()
|
||||
.lines()
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_line_is_answered_and_a_bad_one_ends_it() {
|
||||
let ok = r#"{"proto":1,"start_host":null}"#;
|
||||
let other = r#"{"proto":2,"start_host":null}"#;
|
||||
let got = served(format!("{ok}\n{other}\nnonsense\n{ok}\n").as_bytes());
|
||||
assert_eq!(got[0], r#""done""#);
|
||||
assert!(got[1].contains("another version"), "{}", got[1]);
|
||||
assert!(got[2].contains("not understood"), "{}", got[2]);
|
||||
assert_eq!(got[3], r#""done""#);
|
||||
assert_eq!(got.len(), 4);
|
||||
// A line at the limit is read; one byte more ends the helper,
|
||||
// and so does one cut off without its newline.
|
||||
let at = format!("{ok}{}\n", " ".repeat(MAX_LINE as usize - ok.len()));
|
||||
assert_eq!(served(at.as_bytes()), [r#""done""#]);
|
||||
let over = format!(
|
||||
"{ok}{}\n{ok}\n",
|
||||
" ".repeat(MAX_LINE as usize - ok.len() + 1)
|
||||
);
|
||||
assert!(served(over.as_bytes()).is_empty());
|
||||
assert!(served(ok.as_bytes()).is_empty());
|
||||
// An answer that can't be written ends it.
|
||||
struct Broken;
|
||||
impl Write for Broken {
|
||||
fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
|
||||
Err(std::io::ErrorKind::BrokenPipe.into())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
let asked = std::cell::Cell::new(0);
|
||||
serve(format!("{ok}\n{ok}\n").as_bytes(), Broken, |_| {
|
||||
asked.set(asked.get() + 1);
|
||||
Answer::Done
|
||||
});
|
||||
assert_eq!(asked.get(), 1, "nothing more after the first answer failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_isnt_built_yet_is_refused() {
|
||||
let k = Knobs::default();
|
||||
for ask in [
|
||||
HelperAsk::OpenWith { attachment: 1 },
|
||||
HelperAsk::StageBackup,
|
||||
HelperAsk::StartUpdater,
|
||||
] {
|
||||
assert!(matches!(answer(ask, None, &k), Answer::Refused(_)));
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,150 @@
|
||||
//! The host's and bridge's timings, which tests shorten (`--test-knobs`).
|
||||
//!
|
||||
//! Every mode that starts another passes its knobs on, so a test sets
|
||||
//! them once, on the holder it starts. Nothing else sets them: the
|
||||
//! defaults are the plan's (3.9).
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
/// The timings in force.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Knobs {
|
||||
/// How long the host waits after the last lease ends before it
|
||||
/// closes the books (plan: 10 s).
|
||||
pub close_after: Duration,
|
||||
/// How often the host looks whether its executable was replaced.
|
||||
pub exe_poll: Duration,
|
||||
/// The first wait before a holder starts a failed host again; it
|
||||
/// doubles each time (plan: 1, 2, 4 … 60 s, five tries).
|
||||
pub backoff: Duration,
|
||||
/// How long the host waits before starting the server (tests of a
|
||||
/// slow start only; 0 otherwise).
|
||||
pub server_delay: Duration,
|
||||
/// How long the bridge waits for the server before answering
|
||||
/// `initialize` itself (plan: 5 s).
|
||||
pub init_wait: Duration,
|
||||
/// How long the bridge holds a call while the books open (plan: 60 s).
|
||||
pub queue_wait: Duration,
|
||||
/// How long a command waits for the books to open.
|
||||
pub open_wait: Duration,
|
||||
}
|
||||
|
||||
impl Default for Knobs {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
close_after: Duration::from_secs(10),
|
||||
exe_poll: Duration::from_secs(5),
|
||||
backoff: Duration::from_secs(1),
|
||||
server_delay: Duration::ZERO,
|
||||
init_wait: Duration::from_secs(5),
|
||||
queue_wait: Duration::from_secs(60),
|
||||
open_wait: Duration::from_secs(120),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The flag that carries them.
|
||||
pub const FLAG: &str = "--test-knobs";
|
||||
|
||||
impl Knobs {
|
||||
/// Read `name=millis,...` (any subset; the rest stay default).
|
||||
pub fn parse(text: &str) -> Result<Self, String> {
|
||||
let mut k = Self::default();
|
||||
for pair in text.split(',').filter(|p| !p.is_empty()) {
|
||||
let (name, value) = pair
|
||||
.split_once('=')
|
||||
.ok_or_else(|| format!("{FLAG}: {pair:?} isn't name=milliseconds"))?;
|
||||
let ms: u64 = value
|
||||
.parse()
|
||||
.map_err(|_| format!("{FLAG}: {value:?} isn't a number of milliseconds"))?;
|
||||
let d = Duration::from_millis(ms);
|
||||
match name {
|
||||
"close_after" => k.close_after = d,
|
||||
"exe_poll" => k.exe_poll = d,
|
||||
"backoff" => k.backoff = d,
|
||||
"server_delay" => k.server_delay = d,
|
||||
"init_wait" => k.init_wait = d,
|
||||
"queue_wait" => k.queue_wait = d,
|
||||
"open_wait" => k.open_wait = d,
|
||||
other => return Err(format!("{FLAG}: there is no knob {other:?}")),
|
||||
}
|
||||
}
|
||||
Ok(k)
|
||||
}
|
||||
|
||||
/// The flag's value that gives these knobs, or `None` for the
|
||||
/// defaults (then nothing is passed on).
|
||||
pub fn to_arg(&self) -> Option<String> {
|
||||
let d = Self::default();
|
||||
let mut parts = Vec::new();
|
||||
for (name, now, default) in [
|
||||
("close_after", self.close_after, d.close_after),
|
||||
("exe_poll", self.exe_poll, d.exe_poll),
|
||||
("backoff", self.backoff, d.backoff),
|
||||
("server_delay", self.server_delay, d.server_delay),
|
||||
("init_wait", self.init_wait, d.init_wait),
|
||||
("queue_wait", self.queue_wait, d.queue_wait),
|
||||
("open_wait", self.open_wait, d.open_wait),
|
||||
] {
|
||||
if now != default {
|
||||
parts.push(format!("{name}={}", now.as_millis()));
|
||||
}
|
||||
}
|
||||
(!parts.is_empty()).then(|| parts.join(","))
|
||||
}
|
||||
|
||||
/// The arguments that pass these knobs on to another mode.
|
||||
pub fn args(&self) -> Vec<String> {
|
||||
match self.to_arg() {
|
||||
Some(v) => vec![FLAG.to_string(), v],
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_defaults_are_the_plans() {
|
||||
let k = Knobs::default();
|
||||
assert_eq!(k.close_after, Duration::from_secs(10));
|
||||
assert_eq!(k.backoff, Duration::from_secs(1));
|
||||
assert_eq!(k.init_wait, Duration::from_secs(5));
|
||||
assert_eq!(k.queue_wait, Duration::from_secs(60));
|
||||
assert_eq!(k.exe_poll, Duration::from_secs(5));
|
||||
assert_eq!(k.server_delay, Duration::ZERO);
|
||||
assert_eq!(k.open_wait, Duration::from_secs(120));
|
||||
assert_eq!(k.to_arg(), None);
|
||||
assert!(k.args().is_empty());
|
||||
assert_eq!(Knobs::parse("").unwrap(), k);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn knobs_pass_on_exactly() {
|
||||
let k = Knobs::parse(
|
||||
"close_after=500,backoff=20,server_delay=3000,init_wait=100,queue_wait=900,exe_poll=50,open_wait=70",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(k.close_after, Duration::from_millis(500));
|
||||
assert_eq!(k.backoff, Duration::from_millis(20));
|
||||
assert_eq!(k.server_delay, Duration::from_millis(3000));
|
||||
assert_eq!(k.init_wait, Duration::from_millis(100));
|
||||
assert_eq!(k.queue_wait, Duration::from_millis(900));
|
||||
assert_eq!(k.exe_poll, Duration::from_millis(50));
|
||||
assert_eq!(k.open_wait, Duration::from_millis(70));
|
||||
let args = k.args();
|
||||
assert_eq!(args[0], FLAG);
|
||||
assert_eq!(Knobs::parse(&args[1]).unwrap(), k);
|
||||
let one = Knobs::parse("close_after=1").unwrap();
|
||||
assert_eq!(one.to_arg().as_deref(), Some("close_after=1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_knob_is_refused() {
|
||||
for bad in ["close_after", "close_after=x", "nope=1", "close_after=-1"] {
|
||||
assert!(Knobs::parse(bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
//! The lease socket's messages (`run/lease.sock`, plan 3.9).
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: holders hold the books open, and the owner's few requests reach
|
||||
//! the server, through one socket that accepts nothing else.
|
||||
//! SOURCE: plan/served.md 3.9 ("Who holds them", "Three local channels":
|
||||
//! leases record their kind and PID; exactly five owner intents;
|
||||
//! consent and a replacing restore carry the owner passphrase;
|
||||
//! "Closing": the host says why on each lease; protocol versions N
|
||||
//! and N-1).
|
||||
//! INVARIANTS:
|
||||
//! 1. A connection's first line is one Ask; a Hold keeps the
|
||||
//! connection (the lease lasts until it closes), anything else is
|
||||
//! answered once and the connection closed.
|
||||
//! 2. Exactly five intents exist: close, back_up_now, new_books,
|
||||
//! restore (a staged backup's id, never a path) and
|
||||
//! consent_format_upgrade; any other request is refused.
|
||||
//! 3. consent_format_upgrade, and a restore that replaces a book, carry
|
||||
//! the owner passphrase; without one they are refused before
|
||||
//! anything is done.
|
||||
//! 4. Before the host ends a lease it sends the reason; a lease that
|
||||
//! ends with no reason means the host is gone.
|
||||
//! 5. Each side accepts its own protocol version and the one before.
|
||||
//! 6. Lines are at most MAX_LINE bytes.
|
||||
//! 7. A first line is exactly its version and one ask: anything more
|
||||
//! (two asks, an unknown key) is refused, never half-read.
|
||||
//! ```
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// This protocol's version (INVARIANT 5).
|
||||
pub const PROTO: u32 = 1;
|
||||
/// The oldest version still accepted (there is none before the first).
|
||||
pub const OLDEST: u32 = 1;
|
||||
/// The host's socket, in the root's `run/`.
|
||||
pub const LEASE_SOCKET: &str = "lease.sock";
|
||||
/// Held by the one host for a root.
|
||||
pub const HOST_LOCK: &str = "host.lock";
|
||||
/// Held while the host restarts; openers wait on it.
|
||||
pub const UPDATE_LOCK: &str = "update.lock";
|
||||
/// The longest line either side sends (INVARIANT 6).
|
||||
pub const MAX_LINE: usize = 64 * 1024;
|
||||
|
||||
/// Whether a message of protocol version `proto` is accepted.
|
||||
pub fn accepts(proto: u32) -> bool {
|
||||
(OLDEST..=PROTO).contains(&proto)
|
||||
}
|
||||
|
||||
/// What kind of holder a lease is.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Kind {
|
||||
/// The app (the viewer).
|
||||
App,
|
||||
/// An agent session's bridge (`wanderbooks mcp`).
|
||||
Bridge,
|
||||
/// A terminal running `wanderbooks serve`.
|
||||
Serve,
|
||||
/// A command that needs the server (`wanderbooks books ...`).
|
||||
Cli,
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
/// How a person names it.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Kind::App => "the app",
|
||||
Kind::Bridge => "an agent session",
|
||||
Kind::Serve => "wanderbooks serve",
|
||||
Kind::Cli => "a wanderbooks command",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A connection's first line (INVARIANT 1).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Hello {
|
||||
/// The sender's protocol version.
|
||||
pub proto: u32,
|
||||
/// What it asks.
|
||||
#[serde(flatten)]
|
||||
pub ask: Ask,
|
||||
}
|
||||
|
||||
/// What a connection asks.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum Ask {
|
||||
/// Hold the books open while this connection lasts.
|
||||
Hold {
|
||||
/// Who holds them.
|
||||
kind: Kind,
|
||||
/// The holder's process.
|
||||
pid: u32,
|
||||
/// The book it works on, if one.
|
||||
#[serde(default)]
|
||||
book: Option<String>,
|
||||
},
|
||||
/// The leases held now (the app and `wanderbooks doctor` list them).
|
||||
Leases,
|
||||
/// One of the owner's intents.
|
||||
Intent(Intent),
|
||||
}
|
||||
|
||||
/// The owner's intents: exactly these five (INVARIANT 2).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum Intent {
|
||||
/// Close the books now: every lease ends.
|
||||
Close,
|
||||
/// Back a book up now.
|
||||
BackUpNow {
|
||||
/// Which.
|
||||
book: String,
|
||||
},
|
||||
/// Start new books.
|
||||
NewBooks {
|
||||
/// Their id.
|
||||
id: String,
|
||||
/// Their base currency.
|
||||
currency: String,
|
||||
},
|
||||
/// Restore a staged backup, as its own book or replacing one.
|
||||
Restore {
|
||||
/// The staged backup's id (never a path).
|
||||
staged: String,
|
||||
/// The book it replaces, if it replaces one.
|
||||
#[serde(default)]
|
||||
replace: Option<String>,
|
||||
/// The owner passphrase: needed to replace (INVARIANT 3).
|
||||
#[serde(default)]
|
||||
passphrase: Option<String>,
|
||||
},
|
||||
/// Let the server upgrade a book's journal format.
|
||||
ConsentFormatUpgrade {
|
||||
/// Which.
|
||||
book: String,
|
||||
/// The owner passphrase (INVARIANT 3).
|
||||
#[serde(default)]
|
||||
passphrase: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Intent {
|
||||
/// Refuse an intent that needs the passphrase and has none
|
||||
/// (INVARIANT 3).
|
||||
pub fn check_passphrase(&self) -> Result<(), String> {
|
||||
let missing = |p: &Option<String>| p.as_deref().is_none_or(|p| p.is_empty());
|
||||
match self {
|
||||
Intent::ConsentFormatUpgrade { passphrase, .. } if missing(passphrase) => Err(
|
||||
"consenting to a format upgrade needs the owner passphrase, typed in the app"
|
||||
.into(),
|
||||
),
|
||||
Intent::Restore {
|
||||
replace: Some(_),
|
||||
passphrase,
|
||||
..
|
||||
} if missing(passphrase) => Err(
|
||||
"a restore that replaces books needs the owner passphrase, typed in the app".into(),
|
||||
),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Why the host ends a lease (INVARIANT 4).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Reason {
|
||||
/// The owner closed the books ("Close the books", `wanderbooks
|
||||
/// close`): reopen only in a new session.
|
||||
ClosedByOwner,
|
||||
/// The host is restarting (an update): hold again once it is back.
|
||||
Restarting,
|
||||
/// The host is closing because nothing held it, or its restart or
|
||||
/// start went wrong (never the owner's close, which is
|
||||
/// `ClosedByOwner`): hold again, and a new host will answer.
|
||||
Closing,
|
||||
/// The books server stopped or couldn't start.
|
||||
ServerFailed,
|
||||
}
|
||||
|
||||
/// One lease, as listed.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LeaseInfo {
|
||||
/// Its number.
|
||||
pub id: u64,
|
||||
/// Who holds it.
|
||||
pub kind: Kind,
|
||||
/// The holder's process.
|
||||
pub pid: u32,
|
||||
/// The book it works on, if one.
|
||||
#[serde(default)]
|
||||
pub book: Option<String>,
|
||||
}
|
||||
|
||||
/// What the host sends.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum HostMsg {
|
||||
/// The lease is held; the books are open or opening.
|
||||
Held {
|
||||
/// The lease's number.
|
||||
lease: u64,
|
||||
},
|
||||
/// The books server is ready.
|
||||
Ready,
|
||||
/// The lease ends, and why (INVARIANT 4).
|
||||
Ending {
|
||||
/// Why.
|
||||
reason: Reason,
|
||||
/// More, in plain words (no path).
|
||||
#[serde(default)]
|
||||
detail: Option<String>,
|
||||
},
|
||||
/// An intent was carried out.
|
||||
Done {
|
||||
/// What came of it, in plain words.
|
||||
#[serde(default)]
|
||||
detail: Option<String>,
|
||||
},
|
||||
/// An intent was sent on, and no answer came back: it may or may not
|
||||
/// have been carried out, in plain words.
|
||||
Uncertain(String),
|
||||
/// The leases held now, and whether the books are open yet.
|
||||
Leases {
|
||||
/// The books server is ready.
|
||||
ready: bool,
|
||||
/// The leases.
|
||||
leases: Vec<LeaseInfo>,
|
||||
},
|
||||
/// Refused, with why.
|
||||
Refused(String),
|
||||
}
|
||||
|
||||
/// A connection's first line, read strictly (INVARIANT 7).
|
||||
pub fn parse_hello(text: &str) -> Result<Hello, String> {
|
||||
strict_pair(text).and_then(|v| serde_json::from_value(v).map_err(|e| e.to_string()))
|
||||
}
|
||||
|
||||
/// `text` as a JSON object of exactly two keys, `proto` and one more.
|
||||
pub(crate) fn strict_pair(text: &str) -> Result<serde_json::Value, String> {
|
||||
let value: serde_json::Value = serde_json::from_str(text).map_err(|e| e.to_string())?;
|
||||
match value.as_object() {
|
||||
Some(o) if o.len() == 2 && o.contains_key("proto") => Ok(value),
|
||||
Some(_) => Err("a message is its protocol version and exactly one request".into()),
|
||||
None => Err("a message is a JSON object".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// One line: the message as JSON and a newline.
|
||||
pub fn line<T: Serialize>(message: &T) -> String {
|
||||
let mut text = serde_json::to_string(message).expect("a lease message is plain data");
|
||||
text.push('\n');
|
||||
text
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn parse(v: serde_json::Value) -> Result<Hello, serde_json::Error> {
|
||||
serde_json::from_value(v)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hold_names_its_kind_pid_and_book() {
|
||||
let h = parse(json!({"proto": 1, "hold": {"kind": "bridge", "pid": 7, "book": "acme"}}))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
h.ask,
|
||||
Ask::Hold {
|
||||
kind: Kind::Bridge,
|
||||
pid: 7,
|
||||
book: Some("acme".into())
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(&h).unwrap(),
|
||||
json!({"proto": 1, "hold": {"kind": "bridge", "pid": 7, "book": "acme"}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exactly_five_intents_are_understood() {
|
||||
for intent in [
|
||||
json!("close"),
|
||||
json!({"back_up_now": {"book": "acme"}}),
|
||||
json!({"new_books": {"id": "acme", "currency": "USD"}}),
|
||||
json!({"restore": {"staged": "s1"}}),
|
||||
json!({"consent_format_upgrade": {"book": "acme", "passphrase": "x"}}),
|
||||
] {
|
||||
let h = parse(json!({"proto": 1, "intent": intent})).unwrap();
|
||||
assert!(matches!(h.ask, Ask::Intent(_)), "{intent}");
|
||||
}
|
||||
for bad in [
|
||||
json!({"proto": 1, "intent": "delete"}),
|
||||
json!({"proto": 1, "intent": {"restore": {"path": "/x"}}}),
|
||||
json!({"proto": 1, "intent": {"restore": {"staged": "s1", "path": "/x"}}}),
|
||||
json!({"proto": 1, "intent": {"run": {"command": "sh"}}}),
|
||||
json!({"proto": 1, "exec": "sh"}),
|
||||
json!({"proto": 1}),
|
||||
] {
|
||||
assert!(parse(bad.clone()).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hello_is_exactly_its_version_and_one_ask() {
|
||||
assert!(parse_hello(r#"{"proto":1,"leases":null}"#).is_ok());
|
||||
for bad in [
|
||||
r#"{"proto":1,"intent":{"back_up_now":{"book":"a"}},"leases":null}"#,
|
||||
r#"{"proto":1,"leases":null,"extra":1}"#,
|
||||
r#"{"leases":null,"intent":"close"}"#,
|
||||
r#"{"proto":1}"#,
|
||||
r#"[1]"#,
|
||||
"not json",
|
||||
] {
|
||||
assert!(parse_hello(bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consent_and_a_replacing_restore_need_the_passphrase() {
|
||||
let consent = |p: Option<&str>| Intent::ConsentFormatUpgrade {
|
||||
book: "acme".into(),
|
||||
passphrase: p.map(str::to_string),
|
||||
};
|
||||
assert!(consent(None).check_passphrase().is_err());
|
||||
assert!(consent(Some("")).check_passphrase().is_err());
|
||||
assert!(consent(Some("pw")).check_passphrase().is_ok());
|
||||
let restore = |replace: Option<&str>, p: Option<&str>| Intent::Restore {
|
||||
staged: "s1".into(),
|
||||
replace: replace.map(str::to_string),
|
||||
passphrase: p.map(str::to_string),
|
||||
};
|
||||
assert!(restore(None, None).check_passphrase().is_ok());
|
||||
assert!(restore(Some("acme"), None).check_passphrase().is_err());
|
||||
assert!(restore(Some("acme"), Some("")).check_passphrase().is_err());
|
||||
assert!(restore(Some("acme"), Some("pw")).check_passphrase().is_ok());
|
||||
for other in [
|
||||
Intent::Close,
|
||||
Intent::BackUpNow { book: "a".into() },
|
||||
Intent::NewBooks {
|
||||
id: "a".into(),
|
||||
currency: "USD".into(),
|
||||
},
|
||||
] {
|
||||
assert!(other.check_passphrase().is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versions_this_one_and_the_one_before() {
|
||||
assert!(accepts(PROTO));
|
||||
assert!(!accepts(PROTO + 1));
|
||||
assert!(!accepts(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_messages_round_trip_as_one_line() {
|
||||
for m in [
|
||||
HostMsg::Held { lease: 3 },
|
||||
HostMsg::Ready,
|
||||
HostMsg::Ending {
|
||||
reason: Reason::Restarting,
|
||||
detail: None,
|
||||
},
|
||||
HostMsg::Done {
|
||||
detail: Some("closed".into()),
|
||||
},
|
||||
HostMsg::Leases {
|
||||
ready: true,
|
||||
leases: vec![LeaseInfo {
|
||||
id: 1,
|
||||
kind: Kind::Serve,
|
||||
pid: 9,
|
||||
book: None,
|
||||
}],
|
||||
},
|
||||
HostMsg::Refused("no".into()),
|
||||
HostMsg::Uncertain("maybe".into()),
|
||||
] {
|
||||
let text = line(&m);
|
||||
assert!(text.ends_with('\n') && !text[..text.len() - 1].contains('\n'));
|
||||
assert_eq!(serde_json::from_str::<HostMsg>(text.trim_end()).unwrap(), m);
|
||||
}
|
||||
assert_eq!(Kind::Bridge.label(), "an agent session");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! The books open while something holds them (plan 3.9, D-21, D-23).
|
||||
//!
|
||||
//! Nothing runs in the background: the first holder (the app, an agent's
|
||||
//! bridge, `wanderbooks serve`, or a command that needs the books) starts
|
||||
//! the **host**, which starts the books server and holds it open while
|
||||
//! any lease on its socket lasts, and closes it 10 s after the last one
|
||||
//! ends. Each part is a mode of the one `wanderbooks` executable:
|
||||
//! [`host::run`] (`wanderbooks host`), [`helper::run`] (`wanderbooks
|
||||
//! helper`), [`bridge::run`] (`wanderbooks mcp`), and the commands in
|
||||
//! [`commands`] (`serve`, `close`, `books`).
|
||||
|
||||
#[cfg(unix)]
|
||||
pub mod bridge;
|
||||
#[cfg(unix)]
|
||||
pub mod commands;
|
||||
#[cfg(unix)]
|
||||
pub mod helper;
|
||||
#[cfg(unix)]
|
||||
pub mod host;
|
||||
pub mod knobs;
|
||||
pub mod lease;
|
||||
#[cfg(unix)]
|
||||
pub mod opener;
|
||||
#[cfg(unix)]
|
||||
mod start;
|
||||
|
||||
/// How a mode stops when it can't go on: the reason is already printed,
|
||||
/// and the number is the process's exit code.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Exit(pub i32);
|
||||
|
||||
impl std::fmt::Display for Exit {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "exit code {}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Exit {}
|
||||
@@ -0,0 +1,512 @@
|
||||
//! Holding the books: how every holder takes its lease (plan 3.9).
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: a holder gets its lease on the one host, starting one when none
|
||||
//! answers, even when several holders start at once; across a
|
||||
//! restart it simply holds again; a host that keeps failing is
|
||||
//! given up on, plainly, instead of retried forever.
|
||||
//! SOURCE: plan/served.md 3.9 ("One host, even in a race", "Closing":
|
||||
//! backoff 1, 2, 4 … 60 s, at most five tries; "Restarting":
|
||||
//! openers wait on update.lock); tests host_respawns,
|
||||
//! respawn_backs_off.
|
||||
//! INVARIANTS:
|
||||
//! 1. Before each try the holder waits while a restart holds
|
||||
//! run/update.lock.
|
||||
//! 2. No host answering: it starts one (its Starter) and keeps trying;
|
||||
//! a host that is closing or restarting turns it away and it tries
|
||||
//! again until a host takes the lease.
|
||||
//! 3. A start that brings no lease within START_WAIT (or whose host
|
||||
//! died before taking its lock), a host that doesn't answer, and a
|
||||
//! lease that ends with server_failed or with no reason at all, are
|
||||
//! failures: the holder tries again after knobs.backoff, doubling
|
||||
//! each time (at most 60 s), MAX_TRIES times; the failure after that
|
||||
//! gives up, naming `wanderbooks doctor`. Books that stayed open for
|
||||
//! STABLE clear the count; a server that fails soon after opening
|
||||
//! doesn't.
|
||||
//! 4. The host's end of the socket must be this account.
|
||||
//! 5. A holder that has held before, turned away because the owner
|
||||
//! closed the books, stays closed (HoldError::ClosedByOwner); a new
|
||||
//! holder waits for a new host.
|
||||
//! ```
|
||||
|
||||
use crate::knobs::Knobs;
|
||||
use crate::lease::{self, Ask, Hello, HostMsg, Kind, Reason};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::time::Instant;
|
||||
|
||||
/// How long a started host has to take a lease (longer than a new host
|
||||
/// waits for an old one's lock).
|
||||
const START_WAIT: Duration = Duration::from_secs(45);
|
||||
/// How long a started host has to take its lock: after that, no host
|
||||
/// holding it means the one started has died.
|
||||
const LOCK_WAIT: Duration = Duration::from_secs(2);
|
||||
/// How long the host has to answer a hello.
|
||||
const ANSWER_WAIT: Duration = Duration::from_secs(10);
|
||||
/// Tries after a failure before giving up (INVARIANT 3).
|
||||
pub const MAX_TRIES: u32 = 5;
|
||||
/// How long the books must stay open for earlier failures to be
|
||||
/// forgotten (INVARIANT 3).
|
||||
const STABLE: Duration = Duration::from_secs(60);
|
||||
/// The longest wait between tries.
|
||||
const MAX_BACKOFF: Duration = Duration::from_secs(60);
|
||||
|
||||
/// What a holder says when the host keeps failing.
|
||||
pub const KEEPS_FAILING: &str =
|
||||
"the books server keeps failing to start: run `wanderbooks doctor` to see why";
|
||||
|
||||
/// Why no lease was taken.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum HoldError {
|
||||
/// The owner closed the books, and this holder had held them
|
||||
/// (INVARIANT 5).
|
||||
ClosedByOwner,
|
||||
/// Anything else, in plain words.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for HoldError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
HoldError::ClosedByOwner => write!(f, "the owner closed the books"),
|
||||
HoldError::Failed(why) => f.write_str(why),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for HoldError {
|
||||
fn from(why: String) -> Self {
|
||||
HoldError::Failed(why)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for HoldError {
|
||||
fn from(why: &str) -> Self {
|
||||
HoldError::Failed(why.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// How a holder starts a host when none answers.
|
||||
pub enum Starter {
|
||||
/// Start it itself (a holder that isn't confined).
|
||||
Direct,
|
||||
/// Ask its helper to (a confined holder).
|
||||
Helper(std::sync::Arc<crate::helper::Client>),
|
||||
}
|
||||
|
||||
/// Takes leases for one holder.
|
||||
pub struct Opener {
|
||||
root: Option<PathBuf>,
|
||||
run: PathBuf,
|
||||
knobs: Knobs,
|
||||
starter: Starter,
|
||||
kind: Kind,
|
||||
book: Option<String>,
|
||||
failures: u32,
|
||||
/// When the books last opened on a lease.
|
||||
opened_at: Option<std::time::Instant>,
|
||||
/// Whether this holder has held a lease (INVARIANT 5).
|
||||
has_held: bool,
|
||||
}
|
||||
|
||||
/// A held lease: the books stay open while it lives.
|
||||
pub struct Lease {
|
||||
/// Its number.
|
||||
pub id: u64,
|
||||
lines: tokio::io::Lines<tokio::io::BufReader<tokio::io::Take<tokio::net::unix::OwnedReadHalf>>>,
|
||||
_write: tokio::net::unix::OwnedWriteHalf,
|
||||
}
|
||||
|
||||
impl Lease {
|
||||
/// The host's next word: `Ready`, or `Ending` with why; `None` when
|
||||
/// the host is gone without a word.
|
||||
pub async fn next(&mut self) -> Option<HostMsg> {
|
||||
loop {
|
||||
match self.lines.next_line().await {
|
||||
Ok(Some(line)) => match serde_json::from_str::<HostMsg>(&line) {
|
||||
Ok(msg) => return Some(msg),
|
||||
Err(_) => continue,
|
||||
},
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Opener {
|
||||
/// An opener for the root (`root`, else the one this account's host
|
||||
/// finds), holding as `kind` for `book`.
|
||||
pub fn new(
|
||||
root: Option<&Path>,
|
||||
knobs: Knobs,
|
||||
starter: Starter,
|
||||
kind: Kind,
|
||||
book: Option<String>,
|
||||
) -> Result<Self, String> {
|
||||
let found = ledger_store::root::Root::find(root).map_err(|e| e.to_string())?;
|
||||
let run = found.run_dir().map_err(|e| e.to_string())?;
|
||||
Ok(Self {
|
||||
root: root.map(|_| found.path().to_path_buf()),
|
||||
run,
|
||||
knobs,
|
||||
starter,
|
||||
kind,
|
||||
book,
|
||||
failures: 0,
|
||||
opened_at: None,
|
||||
has_held: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// The timings in force.
|
||||
pub fn knobs(&self) -> Knobs {
|
||||
self.knobs
|
||||
}
|
||||
|
||||
/// The root's `run/` folder.
|
||||
pub fn run_dir(&self) -> &Path {
|
||||
&self.run
|
||||
}
|
||||
|
||||
/// The books opened on a lease (INVARIANT 3).
|
||||
pub fn opened(&mut self) {
|
||||
self.opened_at = Some(std::time::Instant::now());
|
||||
}
|
||||
|
||||
/// Count a failure and wait before the next try, or give up
|
||||
/// (INVARIANT 3).
|
||||
pub async fn failed(&mut self) -> Result<(), String> {
|
||||
if self
|
||||
.opened_at
|
||||
.take()
|
||||
.is_some_and(|at| at.elapsed() >= STABLE)
|
||||
{
|
||||
self.failures = 0;
|
||||
}
|
||||
self.failures += 1;
|
||||
if self.failures > MAX_TRIES {
|
||||
return Err(KEEPS_FAILING.into());
|
||||
}
|
||||
tokio::time::sleep(backoff(self.knobs.backoff, self.failures)).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Take a lease, starting a host if none answers (INVARIANTS 1-4).
|
||||
pub async fn hold(&mut self) -> Result<Lease, HoldError> {
|
||||
let mut started: Option<Instant> = None;
|
||||
loop {
|
||||
wait_for_restart(&self.run).await;
|
||||
let short = ledger_store::root::SocketPath::new(&self.run, lease::LEASE_SOCKET)
|
||||
.map_err(|e| format!("can't reach the books' host: {e}"))?;
|
||||
match tokio::net::UnixStream::connect(short.path()).await {
|
||||
Ok(stream) => {
|
||||
if !ledger_mcp::local::peer_is_us(&stream) {
|
||||
return Err(
|
||||
"the books' host socket belongs to another account: not used".into(),
|
||||
);
|
||||
}
|
||||
match self.ask(stream).await? {
|
||||
Asked::Held(lease) => {
|
||||
self.has_held = true;
|
||||
return Ok(lease);
|
||||
}
|
||||
Asked::OwnerClosed if self.has_held => {
|
||||
return Err(HoldError::ClosedByOwner);
|
||||
}
|
||||
Asked::OwnerClosed => {
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
Asked::Again => tokio::time::sleep(Duration::from_millis(100)).await,
|
||||
Asked::Failed => {
|
||||
started = None;
|
||||
self.failed().await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
// No host answering, or one going away as we came.
|
||||
Err(e) if no_host(&e) => {
|
||||
// A host holding the lock is starting or closing: wait
|
||||
// for it rather than start another.
|
||||
let held = host_lock_held(&self.run);
|
||||
match started {
|
||||
// A start that fails (a spawn error, the helper
|
||||
// gone) is a failure like any other (INVARIANT 3).
|
||||
None if !held => match self.start().await {
|
||||
Ok(()) => started = Some(Instant::now()),
|
||||
Err(_) => self.failed().await?,
|
||||
},
|
||||
None => {}
|
||||
// The one started died (no host holds the lock),
|
||||
// or never answered.
|
||||
Some(at)
|
||||
if (!held && at.elapsed() > LOCK_WAIT) || at.elapsed() > START_WAIT =>
|
||||
{
|
||||
started = None;
|
||||
self.failed().await?;
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(format!("can't reach the books' host: {}", e.kind()).into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a host, through this holder's starter.
|
||||
async fn start(&self) -> Result<(), String> {
|
||||
match &self.starter {
|
||||
Starter::Direct => {
|
||||
let root = self.root.clone();
|
||||
let knobs = self.knobs;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
crate::start::start_host(root.as_deref(), &knobs)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
Starter::Helper(helper) => {
|
||||
let helper = helper.clone();
|
||||
tokio::task::spawn_blocking(move || helper.start_host())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Say hello on a connection and read the host's first word.
|
||||
async fn ask(&self, stream: tokio::net::UnixStream) -> Result<Asked, String> {
|
||||
let (read, mut write) = stream.into_split();
|
||||
let hello = Hello {
|
||||
proto: lease::PROTO,
|
||||
ask: Ask::Hold {
|
||||
kind: self.kind,
|
||||
pid: std::process::id(),
|
||||
book: self.book.clone(),
|
||||
},
|
||||
};
|
||||
if write
|
||||
.write_all(lease::line(&hello).as_bytes())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Ok(Asked::Again);
|
||||
}
|
||||
let mut lines = tokio::io::BufReader::new(read.take(lease::MAX_LINE as u64 * 4)).lines();
|
||||
let first = match tokio::time::timeout(ANSWER_WAIT, lines.next_line()).await {
|
||||
Ok(Ok(Some(line))) => serde_json::from_str::<HostMsg>(&line)
|
||||
.map_err(|e| format!("the books' host answered oddly: {e}"))?,
|
||||
// Gone mid-hello (closing, or a crash): try again.
|
||||
Ok(_) => return Ok(Asked::Again),
|
||||
// Not answering: a failure, tried again after a wait.
|
||||
Err(_) => return Ok(Asked::Failed),
|
||||
};
|
||||
Ok(match first {
|
||||
HostMsg::Held { lease } => Asked::Held(Lease {
|
||||
id: lease,
|
||||
lines,
|
||||
_write: write,
|
||||
}),
|
||||
HostMsg::Ending {
|
||||
reason: Reason::ServerFailed,
|
||||
..
|
||||
} => Asked::Failed,
|
||||
HostMsg::Ending {
|
||||
reason: Reason::ClosedByOwner,
|
||||
..
|
||||
} => Asked::OwnerClosed,
|
||||
HostMsg::Ending { .. } => Asked::Again,
|
||||
HostMsg::Refused(why) => return Err(why),
|
||||
other => return Err(format!("the books' host answered oddly: {other:?}")),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// What a hello came to.
|
||||
enum Asked {
|
||||
Held(Lease),
|
||||
Again,
|
||||
Failed,
|
||||
/// Turned away: the owner is closing the books (INVARIANT 5).
|
||||
OwnerClosed,
|
||||
}
|
||||
|
||||
/// The wait before try `n` (1-based): `base`, doubling, at most 60 s.
|
||||
pub fn backoff(base: Duration, n: u32) -> Duration {
|
||||
let factor = 1u32 << n.saturating_sub(1).min(16);
|
||||
base.saturating_mul(factor).min(MAX_BACKOFF)
|
||||
}
|
||||
|
||||
/// Whether a connect error means no host is answering (none there, or
|
||||
/// one closing as we came), rather than something wrong.
|
||||
fn no_host(e: &std::io::Error) -> bool {
|
||||
matches!(
|
||||
e.kind(),
|
||||
std::io::ErrorKind::NotFound
|
||||
| std::io::ErrorKind::ConnectionRefused
|
||||
| std::io::ErrorKind::ConnectionReset
|
||||
| std::io::ErrorKind::ConnectionAborted
|
||||
| std::io::ErrorKind::BrokenPipe
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether a books server holds run/server.lock now (one finishing after
|
||||
/// its host is gone included).
|
||||
pub fn server_lock_held(run: &Path) -> bool {
|
||||
match std::fs::File::open(run.join(ledger_mcp::local::SERVER_LOCK)) {
|
||||
Ok(file) => matches!(
|
||||
file.try_lock_shared(),
|
||||
Err(std::fs::TryLockError::WouldBlock)
|
||||
),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a host holds run/host.lock now (starting, serving or closing).
|
||||
pub fn host_lock_held(run: &Path) -> bool {
|
||||
match std::fs::File::open(run.join(lease::HOST_LOCK)) {
|
||||
Ok(file) => matches!(
|
||||
file.try_lock_shared(),
|
||||
Err(std::fs::TryLockError::WouldBlock)
|
||||
),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait while a restart holds run/update.lock (INVARIANT 1).
|
||||
async fn wait_for_restart(run: &Path) {
|
||||
// Looked at, not waited on in a thread: a holder that gives up while
|
||||
// a restart runs is never stuck behind it.
|
||||
let path = run.join(lease::UPDATE_LOCK);
|
||||
loop {
|
||||
// No lock file: no restart has ever happened, so none is on.
|
||||
let restarting = std::fs::File::open(&path)
|
||||
.is_ok_and(|f| matches!(f.try_lock_shared(), Err(std::fs::TryLockError::WouldBlock)));
|
||||
if !restarting {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the host one thing on its own connection (leases, an intent):
|
||||
/// `Ok(None)` when no host is running.
|
||||
pub fn ask_once(run: &Path, ask: Ask) -> Result<Option<HostMsg>, String> {
|
||||
ask_once_within(run, ask, Duration::from_secs(120))
|
||||
}
|
||||
|
||||
/// [`ask_once`], waiting at most `wait` for the answer (a probe).
|
||||
pub fn ask_once_within(run: &Path, ask: Ask, wait: Duration) -> Result<Option<HostMsg>, String> {
|
||||
use std::io::{BufRead, Write};
|
||||
let short = ledger_store::root::SocketPath::new(run, lease::LEASE_SOCKET)
|
||||
.map_err(|e| format!("can't reach the books' host: {e}"))?;
|
||||
let stream = match std::os::unix::net::UnixStream::connect(short.path()) {
|
||||
Ok(s) => s,
|
||||
Err(e) if no_host(&e) => return Ok(None),
|
||||
Err(e) => return Err(format!("can't reach the books' host: {}", e.kind())),
|
||||
};
|
||||
if !ledger_mcp::local::peer_is_us(&stream) {
|
||||
return Err("the books' host socket belongs to another account: not used".into());
|
||||
}
|
||||
stream
|
||||
.set_read_timeout(Some(wait))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let not_answering = |e: std::io::Error| match e.kind() {
|
||||
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut => {
|
||||
"the books' host isn't answering".to_string()
|
||||
}
|
||||
kind => format!("the books' host didn't answer: {kind}"),
|
||||
};
|
||||
(&stream)
|
||||
.write_all(
|
||||
lease::line(&Hello {
|
||||
proto: lease::PROTO,
|
||||
ask,
|
||||
})
|
||||
.as_bytes(),
|
||||
)
|
||||
.map_err(|e| format!("can't reach the books' host: {}", e.kind()))?;
|
||||
let mut answer = String::new();
|
||||
std::io::BufReader::new(std::io::Read::take(&stream, lease::MAX_LINE as u64 * 4))
|
||||
.read_line(&mut answer)
|
||||
.map_err(not_answering)?;
|
||||
if answer.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
serde_json::from_str(answer.trim_end())
|
||||
.map(Some)
|
||||
.map_err(|e| format!("the books' host answered oddly: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_wait_doubles_from_the_base_up_to_a_minute() {
|
||||
let s = Duration::from_secs;
|
||||
let waits: Vec<Duration> = (1..=7).map(|n| backoff(s(1), n)).collect();
|
||||
assert_eq!(waits, [s(1), s(2), s(4), s(8), s(16), s(32), s(60)]);
|
||||
assert_eq!(backoff(s(1), 0), s(1));
|
||||
assert_eq!(backoff(s(1), 400), s(60));
|
||||
assert_eq!(
|
||||
backoff(Duration::from_millis(10), 3),
|
||||
Duration::from_millis(40)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn after_five_failures_in_a_row_it_gives_up() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut o = Opener {
|
||||
root: None,
|
||||
run: dir.path().to_path_buf(),
|
||||
knobs: Knobs {
|
||||
backoff: Duration::from_millis(1),
|
||||
..Knobs::default()
|
||||
},
|
||||
starter: Starter::Direct,
|
||||
kind: Kind::Cli,
|
||||
book: None,
|
||||
failures: 0,
|
||||
opened_at: None,
|
||||
has_held: false,
|
||||
};
|
||||
for _ in 0..MAX_TRIES {
|
||||
o.failed().await.unwrap();
|
||||
}
|
||||
assert_eq!(o.failed().await.unwrap_err(), KEEPS_FAILING);
|
||||
// Opening briefly doesn't clear the count; staying open does.
|
||||
o.opened();
|
||||
assert!(o.failed().await.is_err(), "a brief open cleared the count");
|
||||
o.opened_at = Some(std::time::Instant::now() - STABLE);
|
||||
assert!(o.failed().await.is_ok(), "staying open clears the count");
|
||||
assert_eq!(o.failures, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_host_going_away_is_no_host() {
|
||||
use std::io::{Error, ErrorKind};
|
||||
for kind in [
|
||||
ErrorKind::NotFound,
|
||||
ErrorKind::ConnectionRefused,
|
||||
ErrorKind::ConnectionReset,
|
||||
ErrorKind::ConnectionAborted,
|
||||
ErrorKind::BrokenPipe,
|
||||
] {
|
||||
assert!(no_host(&Error::from(kind)), "{kind:?}");
|
||||
}
|
||||
for kind in [ErrorKind::PermissionDenied, ErrorKind::InvalidInput] {
|
||||
assert!(!no_host(&Error::from(kind)), "{kind:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_host_is_none() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert_eq!(ask_once(dir.path(), Ask::Leases).unwrap(), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
//! Starting the host cleanly (plan 3.9, "Starting the host cleanly").
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: the host never inherits anything from whoever opened the books:
|
||||
//! not their environment, descriptors, session, umask, or the app's
|
||||
//! scope (which would take the host down with the app).
|
||||
//! SOURCE: plan/served.md 3.9; test host_starts_clean, host_survives_app_scope.
|
||||
//! INVARIANTS:
|
||||
//! 1. The host's environment is a fixed list: PATH (fixed), HOME (from
|
||||
//! the account's record, never the opener's), and XDG_RUNTIME_DIR
|
||||
//! and DBUS_SESSION_BUS_ADDRESS only when they point at this
|
||||
//! account's own private runtime folder. Nothing else passes (a
|
||||
//! scope's own identifiers, such as INVOCATION_ID, are systemd's).
|
||||
//! 2. Its standard input and output are null, its errors go to
|
||||
//! run/host.log (0600); it starts a new session with umask 077. Every
|
||||
//! other descriptor is closed (std opens them close-on-exec, and the
|
||||
//! host closes any stray one at start).
|
||||
//! 3. Under a systemd app scope it starts through a transient
|
||||
//! `systemd-run --user --scope` (nothing installed or kept); if the
|
||||
//! scope couldn't be made (systemd-run failed without the host ever
|
||||
//! writing its first line), or there is no such scope, directly. A
|
||||
//! host that ran and stopped is never started a second time.
|
||||
//! 4. It runs this executable's file as it is on disk now.
|
||||
//! 5. The opener doesn't wait for it, and reaps it when it ends.
|
||||
//! ```
|
||||
|
||||
use crate::knobs::Knobs;
|
||||
use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// The host's PATH (INVARIANT 1).
|
||||
const PATH: &str = "/usr/local/bin:/usr/bin:/bin";
|
||||
|
||||
/// Where systemd-run may be (never looked up on the opener's PATH).
|
||||
const SYSTEMD_RUN: [&str; 2] = ["/usr/bin/systemd-run", "/bin/systemd-run"];
|
||||
|
||||
/// The host's log, in the root's `run/`.
|
||||
pub const HOST_LOG: &str = "host.log";
|
||||
/// A log larger than this is moved aside when a host starts.
|
||||
const LOG_LIMIT: u64 = 8 * 1024 * 1024;
|
||||
|
||||
/// This executable's file as it is on disk now (INVARIANT 4): on Linux a
|
||||
/// replaced file reads as "… (deleted)"; the path, not the old file, is
|
||||
/// what a new host runs.
|
||||
pub fn this_executable() -> Result<PathBuf, String> {
|
||||
let exe = std::env::current_exe().map_err(|e| format!("can't find this program: {e}"))?;
|
||||
let text = exe.to_string_lossy();
|
||||
if let Some(path) = text.strip_suffix(" (deleted)")
|
||||
&& !exe.exists()
|
||||
{
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
Ok(exe)
|
||||
}
|
||||
|
||||
/// This account's user ID.
|
||||
fn own_uid() -> u32 {
|
||||
// SAFETY: geteuid has no preconditions and can't fail.
|
||||
unsafe { libc::geteuid() }
|
||||
}
|
||||
|
||||
/// `XDG_RUNTIME_DIR` from `value`, if it is this account's own private
|
||||
/// runtime folder: absolute, a real folder (no link), owned by this
|
||||
/// account, and closed to everyone else.
|
||||
pub fn own_runtime_dir(value: Option<&std::ffi::OsStr>) -> Option<PathBuf> {
|
||||
// Written plainly (no trailing slash, no `.`), as a bus address
|
||||
// names it.
|
||||
let dir: PathBuf = PathBuf::from(value?).components().collect();
|
||||
if !dir.is_absolute() {
|
||||
return None;
|
||||
}
|
||||
let meta = std::fs::symlink_metadata(&dir).ok()?;
|
||||
(meta.is_dir() && meta.uid() == own_uid() && meta.mode() & 0o077 == 0).then_some(dir)
|
||||
}
|
||||
|
||||
/// `DBUS_SESSION_BUS_ADDRESS` from `value`, if it is the bus in `runtime`
|
||||
/// (`unix:path=<runtime>/bus`, perhaps with a guid after a comma).
|
||||
pub fn own_bus(value: Option<&std::ffi::OsStr>, runtime: &Path) -> Option<String> {
|
||||
let value = value?.to_str()?;
|
||||
let want = format!("unix:path={}/bus", runtime.display());
|
||||
let rest = value.strip_prefix(&want)?;
|
||||
(rest.is_empty() || rest.starts_with(',')).then(|| value.to_string())
|
||||
}
|
||||
|
||||
/// This account's runtime folder: the one named in the environment if it
|
||||
/// is ours and private; else, when the opener's environment has none (a
|
||||
/// client that scrubs it, as Codex does), the system's for this account,
|
||||
/// `/run/user/<uid>`, under the same checks.
|
||||
fn runtime_dir() -> Option<PathBuf> {
|
||||
// The one named, if it passes; else (none named, or one that isn't
|
||||
// ours or private) the system's for this account.
|
||||
std::env::var_os("XDG_RUNTIME_DIR")
|
||||
.and_then(|named| own_runtime_dir(Some(&named)))
|
||||
.or_else(|| own_runtime_dir(Some(format!("/run/user/{}", own_uid()).as_ref())))
|
||||
}
|
||||
|
||||
/// The session bus in `runtime`: the one named in the environment if it
|
||||
/// is there, else its usual socket there, if one exists.
|
||||
fn session_bus(runtime: &Path) -> Option<String> {
|
||||
match std::env::var_os("DBUS_SESSION_BUS_ADDRESS") {
|
||||
Some(named) => own_bus(Some(&named), runtime),
|
||||
None => {
|
||||
use std::os::unix::fs::FileTypeExt;
|
||||
let socket = runtime.join("bus");
|
||||
std::fs::symlink_metadata(&socket)
|
||||
.is_ok_and(|m| m.file_type().is_socket() && m.uid() == own_uid())
|
||||
.then(|| format!("unix:path={}", socket.display()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The host's environment (INVARIANT 1).
|
||||
fn host_env(home: &Path) -> Vec<(String, std::ffi::OsString)> {
|
||||
let mut env = vec![
|
||||
("PATH".to_string(), PATH.into()),
|
||||
("HOME".to_string(), home.as_os_str().to_os_string()),
|
||||
];
|
||||
if let Some(runtime) = runtime_dir() {
|
||||
if let Some(bus) = session_bus(&runtime) {
|
||||
env.push(("DBUS_SESSION_BUS_ADDRESS".into(), bus.into()));
|
||||
}
|
||||
env.push(("XDG_RUNTIME_DIR".into(), runtime.into_os_string()));
|
||||
}
|
||||
env
|
||||
}
|
||||
|
||||
/// Whether this process sits in a unit under this account's user manager
|
||||
/// (an app's scope or service, as uwsm makes for every app), whose stop
|
||||
/// would take the host with it; going by the unified cgroup line of
|
||||
/// `/proc/self/cgroup`. The manager's own `init.scope` is not such a
|
||||
/// unit.
|
||||
pub fn in_app_scope(cgroup: &str, uid: u32) -> bool {
|
||||
let manager = format!("/user@{uid}.service/");
|
||||
cgroup.lines().any(|line| {
|
||||
line.strip_prefix("0::").is_some_and(|path| {
|
||||
path.split_once(manager.as_str())
|
||||
.is_some_and(|(_, unit)| !unit.is_empty() && unit != "init.scope")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// systemd-run, if this process is in an app scope and the user manager
|
||||
/// can be reached (INVARIANT 3).
|
||||
fn systemd_run() -> Option<&'static str> {
|
||||
let cgroup = std::fs::read_to_string("/proc/self/cgroup").ok()?;
|
||||
if !in_app_scope(&cgroup, own_uid()) {
|
||||
return None;
|
||||
}
|
||||
// systemd-run reaches the user manager through the runtime folder,
|
||||
// which host_env gives it even when the opener's environment didn't.
|
||||
runtime_dir()?;
|
||||
SYSTEMD_RUN.into_iter().find(|p| Path::new(p).is_file())
|
||||
}
|
||||
|
||||
/// Start a host for the root (`root`, else the one the host finds
|
||||
/// itself), with these knobs (INVARIANTS 1-5).
|
||||
pub fn start_host(root: Option<&Path>, knobs: &Knobs) -> Result<(), String> {
|
||||
let found = ledger_store::root::Root::find(root).map_err(|e| e.to_string())?;
|
||||
let run = found.run_dir().map_err(|e| e.to_string())?;
|
||||
let home = ledger_store::root::account_home().map_err(|e| e.to_string())?;
|
||||
let exe = this_executable()?;
|
||||
let mut args: Vec<std::ffi::OsString> = vec!["host".into()];
|
||||
// An explicit root goes on as found (absolute): the host starts in /.
|
||||
if root.is_some() {
|
||||
args.push("--root".into());
|
||||
args.push(found.path().as_os_str().to_os_string());
|
||||
}
|
||||
args.extend(knobs.args().into_iter().map(Into::into));
|
||||
let log = || {
|
||||
let path = run.join(HOST_LOG);
|
||||
// A large log is moved aside first (to `.1`), never from under a
|
||||
// host writing it.
|
||||
if std::fs::metadata(&path).is_ok_and(|m| m.len() > LOG_LIMIT)
|
||||
&& !crate::opener::host_lock_held(&run)
|
||||
{
|
||||
let _ = std::fs::rename(&path, path.with_extension("log.1"));
|
||||
}
|
||||
std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.append(true)
|
||||
.mode(0o600)
|
||||
.open(&path)
|
||||
.map_err(|e| format!("can't open the host's log: {}", e.kind()))
|
||||
};
|
||||
// Through systemd-run (`Some`), or directly.
|
||||
let command = |through: Option<&str>, log: std::fs::File| -> Result<Command, String> {
|
||||
let mut c = match through {
|
||||
Some(systemd_run) => {
|
||||
let mut c = Command::new(systemd_run);
|
||||
c.args(["--user", "--scope", "--quiet", "--collect", "--"])
|
||||
.arg(&exe);
|
||||
c
|
||||
}
|
||||
None => Command::new(&exe),
|
||||
};
|
||||
c.args(&args)
|
||||
.env_clear()
|
||||
.envs(host_env(&home))
|
||||
.current_dir("/")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(log);
|
||||
// SAFETY: setsid and umask are async-signal-safe and touch only
|
||||
// the child.
|
||||
unsafe {
|
||||
c.pre_exec(|| {
|
||||
libc::setsid();
|
||||
libc::umask(0o077);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
Ok(c)
|
||||
};
|
||||
if let Some(systemd_run) = systemd_run() {
|
||||
// The log as this start opened it, and where it stands now: what
|
||||
// the host writes after this is read back from that same file,
|
||||
// even if another opener moves the log aside meanwhile.
|
||||
let file = log()?;
|
||||
let from = file.metadata().map_or(0, |m| m.len());
|
||||
let watch = file
|
||||
.try_clone()
|
||||
.map_err(|e| format!("can't open the host's log: {}", e.kind()))?;
|
||||
if let Ok(mut child) = command(Some(systemd_run), file)?.spawn()
|
||||
&& scope_made(&mut child, &watch, from)
|
||||
{
|
||||
reap(child);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
let child = command(None, log()?)?
|
||||
.spawn()
|
||||
.map_err(|e| format!("can't start the books' host: {e}"))?;
|
||||
reap(child);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// How long systemd-run has to make its scope and run the host.
|
||||
const SCOPE_WAIT: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Whether systemd-run made its scope and ran the host (INVARIANT 3): the
|
||||
/// host's first line is in its log after `from`, or systemd-run is still
|
||||
/// running when SCOPE_WAIT is up (it became the host). Only systemd-run
|
||||
/// failing with no host line means it didn't: then the host is started
|
||||
/// directly. (systemd-run's own complaints go to the same log, so the
|
||||
/// log growing proves nothing; the host's line does.)
|
||||
fn scope_made(child: &mut std::process::Child, log: &std::fs::File, from: u64) -> bool {
|
||||
let host_ran = || {
|
||||
use std::os::unix::fs::FileExt;
|
||||
let len = log.metadata().map_or(0, |m| m.len());
|
||||
let mut tail = vec![0u8; len.saturating_sub(from).min(64 * 1024) as usize];
|
||||
log.read_exact_at(&mut tail, from).is_ok()
|
||||
&& String::from_utf8_lossy(&tail).contains(crate::host::STARTED)
|
||||
};
|
||||
let deadline = Instant::now() + SCOPE_WAIT;
|
||||
loop {
|
||||
if host_ran() {
|
||||
return true;
|
||||
}
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => return status.success() || host_ran(),
|
||||
Ok(None) if Instant::now() < deadline => {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
_ => return true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reap a started host when it ends, without waiting for it (INVARIANT 5).
|
||||
fn reap(mut child: std::process::Child) {
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("reap-host".into())
|
||||
.spawn(move || {
|
||||
let _ = child.wait();
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
#[test]
|
||||
fn an_app_scope_is_known_by_its_unified_cgroup() {
|
||||
let uwsm = "0::/user.slice/user-1000.slice/user@1000.service/app.slice/app-Hyprland-kitty-1234.scope\n";
|
||||
assert!(in_app_scope(uwsm, 1000));
|
||||
assert!(!in_app_scope(uwsm, 1001), "another account's manager");
|
||||
// An app run as a service unit (uwsm's app-…@….service) too.
|
||||
assert!(in_app_scope(
|
||||
"0::/user.slice/user-1000.slice/user@1000.service/app.slice/app-foo@x.service\n",
|
||||
1000
|
||||
));
|
||||
for not in [
|
||||
"0::/user.slice/user-1000.slice/session-2.scope\n",
|
||||
"0::/user.slice/user-1000.slice/user@1000.service/init.scope\n",
|
||||
"0::/user.slice/user-1000.slice/user@1000.service/\n",
|
||||
"0::/\n",
|
||||
"1:name=systemd:/user.slice/user-1000.slice/user@1000.service/app.slice/x.scope\n",
|
||||
"",
|
||||
] {
|
||||
assert!(!in_app_scope(not, 1000), "{not}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_our_own_private_runtime_folder_passes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let rt = dir.path().join("rt");
|
||||
std::fs::create_dir(&rt).unwrap();
|
||||
std::fs::set_permissions(&rt, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
assert_eq!(own_runtime_dir(Some(rt.as_os_str())), Some(rt.clone()));
|
||||
std::fs::set_permissions(&rt, std::fs::Permissions::from_mode(0o750)).unwrap();
|
||||
assert_eq!(
|
||||
own_runtime_dir(Some(rt.as_os_str())),
|
||||
None,
|
||||
"group can enter"
|
||||
);
|
||||
std::fs::set_permissions(&rt, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let link = dir.path().join("link");
|
||||
std::os::unix::fs::symlink(&rt, &link).unwrap();
|
||||
assert_eq!(own_runtime_dir(Some(link.as_os_str())), None, "a link");
|
||||
assert_eq!(own_runtime_dir(Some("relative/rt".as_ref())), None);
|
||||
assert_eq!(
|
||||
own_runtime_dir(Some(dir.path().join("none").as_os_str())),
|
||||
None
|
||||
);
|
||||
assert_eq!(own_runtime_dir(None), None);
|
||||
// Owned by root, not us.
|
||||
if own_uid() != 0 {
|
||||
assert_eq!(own_runtime_dir(Some("/".as_ref())), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_bus_in_our_runtime_folder_passes() {
|
||||
let rt = Path::new("/run/user/1000");
|
||||
assert_eq!(
|
||||
own_bus(Some("unix:path=/run/user/1000/bus".as_ref()), rt).as_deref(),
|
||||
Some("unix:path=/run/user/1000/bus")
|
||||
);
|
||||
assert!(own_bus(Some("unix:path=/run/user/1000/bus,guid=ab".as_ref()), rt).is_some());
|
||||
for bad in [
|
||||
"unix:path=/tmp/evil/bus",
|
||||
"unix:path=/run/user/1000/bus2",
|
||||
"unix:path=/run/user/1000/busx,guid=1",
|
||||
"tcp:host=evil.example,port=1",
|
||||
"unix:abstract=/run/user/1000/bus",
|
||||
] {
|
||||
assert_eq!(own_bus(Some(bad.as_ref()), rt), None, "{bad}");
|
||||
}
|
||||
assert_eq!(own_bus(None, rt), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_host_environment_carries_nothing_else() {
|
||||
let env = host_env(Path::new("/home/someone"));
|
||||
let names: Vec<&str> = env.iter().map(|(n, _)| n.as_str()).collect();
|
||||
assert_eq!(&names[..2], ["PATH", "HOME"]);
|
||||
for n in &names[2..] {
|
||||
assert!(
|
||||
["XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS"].contains(n),
|
||||
"{n}"
|
||||
);
|
||||
}
|
||||
assert_eq!(env[0].1, PATH);
|
||||
assert_eq!(env[1].1, "/home/someone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn this_executable_is_a_file_on_disk() {
|
||||
let exe = this_executable().unwrap();
|
||||
assert!(exe.is_file(), "{exe:?}");
|
||||
}
|
||||
}
|
||||
@@ -24,13 +24,15 @@ mime_guess = "2"
|
||||
rust_decimal = "1"
|
||||
rmcp = { version = "3", features = ["server", "transport-io", "schemars", "transport-streamable-http-server"] }
|
||||
axum = "0.8"
|
||||
http-body-util = "0.1"
|
||||
tower = "0.5"
|
||||
schemars = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
chrono = "0.4"
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "io-std", "sync"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "io-std", "sync", "net", "signal", "time"] }
|
||||
tokio-util = { version = "0.7", features = ["rt"] }
|
||||
zip = "9.0.1"
|
||||
toml = "1"
|
||||
# The vault's TINs, decoded for the filing scan (tin_files.rs), are wiped
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
//! The admin socket's messages (`run/admin.sock`, plan 3.1, 3.9): how the
|
||||
//! command line asks the books server to list, create and adopt books.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: the command line makes and adopts books without ever touching
|
||||
//! the data root itself: the server, its sole writer, does it.
|
||||
//! SOURCE: plan/served.md 3.1 (the CLI "talks to run/admin.sock ... it can
|
||||
//! create or adopt books"), 5.5 (adoption), 3.9 (protocol versions
|
||||
//! N and N-1).
|
||||
//! INVARIANTS:
|
||||
//! 1. One request per connection, one JSON line each way, at most
|
||||
//! MAX_LINE bytes; anything else is refused with a plain reason.
|
||||
//! 2. A request carries its protocol version; the server accepts this
|
||||
//! version and the one before, and refuses others with a message
|
||||
//! that says to restart.
|
||||
//! 3. No answer names the data root or a path in it, nor echoes a path
|
||||
//! the caller gave (which could be the root under another name).
|
||||
//! 4. Adopting is two steps: without `confirmed` the answer is a
|
||||
//! preview, and nothing is written; `confirmed: true` adopts, and
|
||||
//! only books still at the head the preview read (`expect_head`).
|
||||
//! 5. Nothing in a settings file is dropped silently: entries the books
|
||||
//! don't know are refused unless `skip_unknown`, and its notes and
|
||||
//! comments are in the preview.
|
||||
//! ```
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// This protocol's version (INVARIANT 2).
|
||||
pub const PROTO: u32 = 1;
|
||||
|
||||
/// The oldest version this side still accepts (INVARIANT 2: this one and
|
||||
/// the one before; there is none before the first).
|
||||
pub const OLDEST: u32 = 1;
|
||||
|
||||
/// Whether a request of protocol version `proto` is accepted.
|
||||
pub fn accepts(proto: u32) -> bool {
|
||||
(OLDEST..=PROTO).contains(&proto)
|
||||
}
|
||||
|
||||
/// The longest line either side sends, its newline not counted
|
||||
/// (INVARIANT 1): settings texts ride in an adopt request.
|
||||
pub const MAX_LINE: usize = 4 * 1024 * 1024;
|
||||
|
||||
/// A request, with its protocol version.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Envelope {
|
||||
/// The sender's protocol version.
|
||||
pub proto: u32,
|
||||
/// What is asked.
|
||||
#[serde(flatten)]
|
||||
pub request: Request,
|
||||
}
|
||||
|
||||
/// What the command line can ask.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum Request {
|
||||
/// The books the server holds.
|
||||
List,
|
||||
/// Start new, empty books.
|
||||
Create {
|
||||
/// The new books' id.
|
||||
id: String,
|
||||
/// Their base currency (ISO 4217), fixed for good.
|
||||
currency: String,
|
||||
},
|
||||
/// Adopt existing books (plan 5.5).
|
||||
Adopt {
|
||||
/// The new books' id.
|
||||
id: String,
|
||||
/// The data dir to adopt (`journal.jsonl`, `blobs/`).
|
||||
source: PathBuf,
|
||||
/// The settings file's text, for books that keep no settings.
|
||||
#[serde(default)]
|
||||
settings_toml: Option<String>,
|
||||
/// `config.json`'s text, for its receipt policy.
|
||||
#[serde(default)]
|
||||
config_json: Option<String>,
|
||||
/// The source's TIN vault folder.
|
||||
#[serde(default)]
|
||||
vault_dir: Option<PathBuf>,
|
||||
/// The v1 vault key file.
|
||||
#[serde(default)]
|
||||
vault_key: Option<PathBuf>,
|
||||
/// Adopt (true), or only preview what would be moved in.
|
||||
#[serde(default)]
|
||||
confirmed: bool,
|
||||
/// The head the preview read: adopted only if still so.
|
||||
#[serde(default)]
|
||||
expect_head: Option<(u64, String)>,
|
||||
/// Leave out settings-file entries the books don't know.
|
||||
#[serde(default)]
|
||||
skip_unknown: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// One book, as listed.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BookStatus {
|
||||
/// Its id.
|
||||
pub id: String,
|
||||
/// Whether it is open and served.
|
||||
pub open: bool,
|
||||
/// Why it couldn't be opened, if it couldn't.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub problem: Option<String>,
|
||||
}
|
||||
|
||||
/// A setting an adoption would move in.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SettingChange {
|
||||
/// Its key.
|
||||
pub key: String,
|
||||
/// Its value, as JSON (`None`: the setting is cleared).
|
||||
pub value: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// What an unconfirmed adoption would do (INVARIANT 4).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AdoptPreview {
|
||||
/// The source's last event, `(seq, hash)`, as read now.
|
||||
pub source_head: (u64, String),
|
||||
/// The source already keeps its own settings: none would be moved in.
|
||||
pub keeps_settings: bool,
|
||||
/// The settings that would be moved in.
|
||||
pub settings: Vec<SettingChange>,
|
||||
/// What the settings file says that isn't a books setting.
|
||||
pub notes: Vec<String>,
|
||||
/// Entries in it the books don't know (not moved in).
|
||||
pub unknown: Vec<String>,
|
||||
/// The settings file's comments, in order (they aren't settings: keep
|
||||
/// what matters as a convention or a decision).
|
||||
pub comments: Vec<String>,
|
||||
/// The vault's records, each checked to open with the key given.
|
||||
pub vault_records: usize,
|
||||
}
|
||||
|
||||
/// What an adoption did.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Adopted {
|
||||
/// The new books' id.
|
||||
pub id: String,
|
||||
/// The source's head, verified before copying.
|
||||
pub source_head: (u64, String),
|
||||
/// The adopted books' head.
|
||||
pub head: (u64, String),
|
||||
/// Stored files copied.
|
||||
pub blob_files: u64,
|
||||
/// Stored files the books name, re-hashed through the store.
|
||||
pub blobs_verified: u64,
|
||||
/// Stored files copied that the books don't name.
|
||||
pub unnamed_blobs: u64,
|
||||
/// Files left behind among the stored ones (unfinished writes, hidden
|
||||
/// files).
|
||||
pub skipped_files: u64,
|
||||
/// Vault records copied.
|
||||
pub vault_files: u64,
|
||||
/// Whether the v1 key was copied.
|
||||
pub key_copied: bool,
|
||||
/// Settings moved in.
|
||||
pub settings_imported: usize,
|
||||
/// Settings were given, but the books keep their own.
|
||||
pub settings_ignored: bool,
|
||||
/// The source had no lock file, so one was made beside its journal.
|
||||
pub source_lock_created: bool,
|
||||
/// Why the adopted books didn't open, if they didn't (they are kept;
|
||||
/// closing the books and opening them again tries again).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub problem: Option<String>,
|
||||
}
|
||||
|
||||
/// The server's answer.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum Response {
|
||||
/// The books held.
|
||||
Books(Vec<BookStatus>),
|
||||
/// New books made.
|
||||
Created {
|
||||
/// Their id.
|
||||
id: String,
|
||||
/// Why they didn't open, if they didn't (they are kept).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
problem: Option<String>,
|
||||
},
|
||||
/// What an adoption would do.
|
||||
Preview(AdoptPreview),
|
||||
/// Books adopted and open.
|
||||
Adopted(Adopted),
|
||||
/// Refused, with why (no path in the data root: INVARIANT 3).
|
||||
Refused(String),
|
||||
}
|
||||
|
||||
/// Why an admin call has no answer.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum CallError {
|
||||
/// The request never reached the server: nothing was done.
|
||||
NotSent(String),
|
||||
/// It was sent, and no answer came: it may or may not have been done.
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CallError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
CallError::NotSent(why) | CallError::Unknown(why) => f.write_str(why),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the server on `socket` (the root's `run/admin.sock`), waiting as
|
||||
/// long as it takes (a create or an adoption).
|
||||
#[cfg(unix)]
|
||||
pub fn call(socket: &std::path::Path, request: Request) -> Result<Response, CallError> {
|
||||
call_within(socket, request, None)
|
||||
}
|
||||
|
||||
/// [`call`], waiting at most `wait` for the answer (a quick look, such
|
||||
/// as `doctor`'s).
|
||||
#[cfg(unix)]
|
||||
pub fn call_within(
|
||||
socket: &std::path::Path,
|
||||
request: Request,
|
||||
wait: Option<std::time::Duration>,
|
||||
) -> Result<Response, CallError> {
|
||||
use std::io::{BufRead, Write};
|
||||
let not_sent = |why: String| CallError::NotSent(why);
|
||||
// A list changes nothing: its only question is whether it answered.
|
||||
let reads_only = matches!(request, Request::List);
|
||||
let unknown = move |why: String| {
|
||||
if reads_only {
|
||||
CallError::Unknown(why)
|
||||
} else {
|
||||
CallError::Unknown(format!(
|
||||
"{why}, so it may or may not have been done: `wanderbooks books list` shows the books there are"
|
||||
))
|
||||
}
|
||||
};
|
||||
let quiet = |e: std::io::Error| match e.kind() {
|
||||
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut => {
|
||||
"the books server isn't answering".to_string()
|
||||
}
|
||||
kind => format!("the books server didn't answer ({kind})"),
|
||||
};
|
||||
let (dir, name) = (
|
||||
socket.parent().unwrap_or(std::path::Path::new(".")),
|
||||
socket
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
let short = ledger_store::root::SocketPath::new(dir, name)
|
||||
.map_err(|e| not_sent(format!("can't reach the books server: {e}")))?;
|
||||
let stream =
|
||||
std::os::unix::net::UnixStream::connect(short.path()).map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused => {
|
||||
not_sent("the books server isn't running".to_string())
|
||||
}
|
||||
_ => not_sent(format!("can't reach the books server: {}", e.kind())),
|
||||
})?;
|
||||
crate::local::check_peer_std(&stream).map_err(not_sent)?;
|
||||
stream
|
||||
.set_read_timeout(wait)
|
||||
.map_err(|e| not_sent(e.to_string()))?;
|
||||
let mut line = serde_json::to_string(&Envelope {
|
||||
proto: PROTO,
|
||||
request,
|
||||
})
|
||||
.map_err(|e| not_sent(e.to_string()))?;
|
||||
line.push('\n');
|
||||
// A write cut part way may still have reached the server whole.
|
||||
(&stream).write_all(line.as_bytes()).map_err(|e| {
|
||||
unknown(format!(
|
||||
"the books server stopped taking the request ({})",
|
||||
e.kind()
|
||||
))
|
||||
})?;
|
||||
let mut answer = String::new();
|
||||
std::io::BufReader::new(std::io::Read::take(&stream, MAX_LINE as u64 + 1))
|
||||
.read_line(&mut answer)
|
||||
.map_err(|e| unknown(quiet(e)))?;
|
||||
if answer.is_empty() {
|
||||
return Err(unknown("the books server stopped before answering".into()));
|
||||
}
|
||||
serde_json::from_str(answer.trim_end())
|
||||
.map_err(|e| unknown(format!("the books server's answer isn't understood ({e})")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn requests_are_one_flat_object_with_their_version() {
|
||||
let line = serde_json::to_value(Envelope {
|
||||
proto: PROTO,
|
||||
request: Request::Create {
|
||||
id: "acme".into(),
|
||||
currency: "USD".into(),
|
||||
},
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
line,
|
||||
json!({"proto": 1, "op": "create", "id": "acme", "currency": "USD"})
|
||||
);
|
||||
let back: Envelope = serde_json::from_value(line).unwrap();
|
||||
assert_eq!(back.proto, 1);
|
||||
let list: Envelope = serde_json::from_value(json!({"proto": 1, "op": "list"})).unwrap();
|
||||
assert_eq!(list.request, Request::List);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_adopt_request_is_a_preview_unless_confirmed() {
|
||||
let r: Envelope = serde_json::from_value(
|
||||
json!({"proto": 1, "op": "adopt", "id": "acme", "source": "/x/books-data"}),
|
||||
)
|
||||
.unwrap();
|
||||
match r.request {
|
||||
Request::Adopt {
|
||||
confirmed,
|
||||
settings_toml,
|
||||
vault_dir,
|
||||
expect_head,
|
||||
skip_unknown,
|
||||
..
|
||||
} => {
|
||||
assert!(!confirmed && !skip_unknown);
|
||||
assert!(settings_toml.is_none() && vault_dir.is_none() && expect_head.is_none());
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_operations_and_fields_are_refused() {
|
||||
for bad in [
|
||||
json!({"proto": 1, "op": "delete", "id": "acme"}),
|
||||
json!({"proto": 1, "op": "create", "id": "acme", "currency": "USD", "path": "/x"}),
|
||||
json!({"proto": 1}),
|
||||
] {
|
||||
assert!(
|
||||
serde_json::from_value::<Envelope>(bad.clone()).is_err(),
|
||||
"{bad}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn answers_round_trip() {
|
||||
for r in [
|
||||
Response::Books(vec![BookStatus {
|
||||
id: "acme".into(),
|
||||
open: false,
|
||||
problem: Some("damaged".into()),
|
||||
}]),
|
||||
Response::Created {
|
||||
id: "acme".into(),
|
||||
problem: None,
|
||||
},
|
||||
Response::Refused("no".into()),
|
||||
] {
|
||||
let text = serde_json::to_string(&r).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Response>(&text).unwrap(), r);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,12 @@
|
||||
//! `/`, the home folder, or holds the books, the vault or a key is refused
|
||||
//! at startup.
|
||||
|
||||
pub mod admin;
|
||||
mod handbook;
|
||||
#[cfg(unix)]
|
||||
pub mod local;
|
||||
mod local_files;
|
||||
pub mod opening;
|
||||
mod policy;
|
||||
mod server;
|
||||
mod settings;
|
||||
@@ -34,6 +38,7 @@ use policy::ReceiptPolicy;
|
||||
use rmcp::ServiceExt;
|
||||
use serde::Deserialize;
|
||||
use server::BooksServer;
|
||||
pub use server::{NOT_RUN_CLOSING, NOT_RUN_CLOSING_CODE};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Server configuration, JSON. All fields optional.
|
||||
@@ -138,6 +143,14 @@ pub fn run(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Books in the data root are the books server's alone (plan 3.1):
|
||||
// a second writer beside it is refused before anything is opened.
|
||||
if ledger_store::root::inside_a_root(&data_dir) {
|
||||
eprintln!(
|
||||
"ledger-mcp: those books are in the books' private folder, which only the books server opens: connect to them with `wanderbooks mcp --book <id>`"
|
||||
);
|
||||
return Err(Box::new(Exit(2)));
|
||||
}
|
||||
let import_roots = if import_roots.is_empty() {
|
||||
ImportRoots::defaults()
|
||||
} else {
|
||||
@@ -221,7 +234,12 @@ pub fn run(
|
||||
// vault or a key (local_files.rs INVARIANT 0): a named one stops the
|
||||
// server here, a default one is dropped.
|
||||
let settings_now = live.lock().unwrap_or_else(|e| e.into_inner()).current();
|
||||
let import_roots = match server::guard_import_roots(&store, &settings_now, import_roots) {
|
||||
let import_roots = match server::guard_import_roots(
|
||||
&store,
|
||||
&settings_now,
|
||||
&server::Layout::BooksDir,
|
||||
import_roots,
|
||||
) {
|
||||
Ok(roots) => roots,
|
||||
Err(why) => {
|
||||
eprintln!("ledger-mcp: {why}");
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,89 @@
|
||||
//! What the agents' bridge answers `initialize` with while the books are
|
||||
//! still opening (plan 3.9, "The bridge and slow starts").
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: an agent whose session starts while the books are closed gets
|
||||
//! its `initialize` answered within its client's startup timeout,
|
||||
//! with capabilities that are the server's own.
|
||||
//! SOURCE: plan/served.md 3.9 (the bridge answers initialize itself after
|
||||
//! 5 s, "from the tool list built into the same executable and a
|
||||
//! short generic text that points at books.start").
|
||||
//! INVARIANTS:
|
||||
//! 1. The capabilities are the server's (one function builds both).
|
||||
//! 2. The protocol version is the one asked for when this build knows
|
||||
//! it and it has an initialize handshake, else the newest that does.
|
||||
//! 3. The text names no book and no folder: it points at books.start.
|
||||
//! ```
|
||||
|
||||
use rmcp::model::{Implementation, ProtocolVersion, ServerCapabilities, ServerConfig};
|
||||
|
||||
/// The text an agent gets while the books open (INVARIANT 3).
|
||||
pub const OPENING_TEXT: &str = "wanderbooks: double-entry books, opening now. Call books.start first: it returns these books' rules, conventions and open items, and every procedure is in books.handbook. Calls wait while the books open.";
|
||||
|
||||
/// The server's capabilities (INVARIANT 1).
|
||||
pub(crate) fn capabilities() -> ServerCapabilities {
|
||||
ServerCapabilities::builder()
|
||||
.enable_tools()
|
||||
.enable_resources()
|
||||
.build()
|
||||
}
|
||||
|
||||
/// The `initialize` result the bridge gives while the books open, for a
|
||||
/// client that asked for protocol version `requested`.
|
||||
pub fn opening_initialize(requested: Option<&str>) -> serde_json::Value {
|
||||
let version = requested
|
||||
.and_then(|asked| {
|
||||
ProtocolVersion::KNOWN_VERSIONS
|
||||
.iter()
|
||||
.find(|v| v.as_str() == asked && v.has_initialize())
|
||||
})
|
||||
.cloned()
|
||||
.unwrap_or(ProtocolVersion::LATEST_WITH_INITIALIZE);
|
||||
let mut info = ServerConfig::new(capabilities())
|
||||
.with_instructions(OPENING_TEXT)
|
||||
.with_server_info(Implementation::new(
|
||||
"wanderbooks",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
));
|
||||
info.protocol_version = version;
|
||||
serde_json::to_value(info).expect("an initialize result is plain data")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_version_asked_for_is_kept_when_known() {
|
||||
for v in ["2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"] {
|
||||
assert_eq!(opening_initialize(Some(v))["protocolVersion"], v);
|
||||
}
|
||||
let newest = ProtocolVersion::LATEST_WITH_INITIALIZE.as_str().to_string();
|
||||
for v in [
|
||||
Some("1999-01-01"),
|
||||
Some("2026-07-28"),
|
||||
Some("nonsense"),
|
||||
None,
|
||||
] {
|
||||
assert_eq!(
|
||||
opening_initialize(v)["protocolVersion"],
|
||||
newest.as_str(),
|
||||
"{v:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_names_the_server_and_points_at_books_start() {
|
||||
let r = opening_initialize(None);
|
||||
assert_eq!(r["serverInfo"]["name"], "wanderbooks");
|
||||
assert_eq!(r["serverInfo"]["version"], env!("CARGO_PKG_VERSION"));
|
||||
assert!(r["instructions"].as_str().unwrap().contains("books.start"));
|
||||
assert_eq!(
|
||||
r["capabilities"],
|
||||
serde_json::to_value(capabilities()).unwrap()
|
||||
);
|
||||
assert!(r["capabilities"]["tools"].is_object());
|
||||
assert!(r["capabilities"]["resources"].is_object());
|
||||
}
|
||||
}
|
||||
+226
-48
@@ -50,7 +50,7 @@ use ledger_store::Store;
|
||||
use rmcp::handler::server::wrapper::{Json, Parameters};
|
||||
use rmcp::model::{
|
||||
Implementation, ListResourcesResult, ReadResourceRequestParams, ReadResourceResponse,
|
||||
ReadResourceResult, Resource, ResourceContents, ServerCapabilities, ServerConfig,
|
||||
ReadResourceResult, Resource, ResourceContents, ServerConfig,
|
||||
};
|
||||
use rmcp::service::{RequestContext, RoleServer};
|
||||
use rmcp::{ServerHandler, tool, tool_handler, tool_router};
|
||||
@@ -70,6 +70,53 @@ pub struct BooksServer {
|
||||
store: Arc<SharedStore>,
|
||||
settings: Arc<Mutex<Live>>,
|
||||
import_roots: Arc<ImportRoots>,
|
||||
layout: Arc<Layout>,
|
||||
}
|
||||
|
||||
/// Where a book's own files live around its data dir.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Layout {
|
||||
/// A books dir, as before P2: the data dir is `books-data/` in a
|
||||
/// project folder (the books dir), which holds `vault/`, the settings
|
||||
/// file and perhaps git; the vault's key lives outside it.
|
||||
BooksDir,
|
||||
/// A book in the data root (plan 3.4): the data dir is `books/<id>/`,
|
||||
/// holding its own `vault/` and its v1 key in `keys/legacy.key`. The
|
||||
/// root (the path given) plays the books dir's part: nothing in it is
|
||||
/// ever read or written through a path a tool names.
|
||||
Root(std::path::PathBuf),
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
/// What a tool never reads or writes through a path: the books dir,
|
||||
/// or the whole data root.
|
||||
fn books_dir(&self, store: &Store) -> std::path::PathBuf {
|
||||
match self {
|
||||
Layout::BooksDir => books_dir(store),
|
||||
Layout::Root(root) => root.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The TIN vault's folder.
|
||||
fn vault_dir(&self, store: &Store) -> std::path::PathBuf {
|
||||
match self {
|
||||
Layout::BooksDir => books_dir(store).join("vault"),
|
||||
Layout::Root(_) => store.data_dir().join("vault"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The vault's key file: in the data root always the book's own
|
||||
/// `keys/legacy.key`, whatever a setting says; in a books dir the one
|
||||
/// in force (`[vault] key_file`, else the default outside the books).
|
||||
fn key_file(&self, store: &Store, settings: &Settings) -> std::path::PathBuf {
|
||||
match self {
|
||||
Layout::BooksDir => settings.key_file(),
|
||||
Layout::Root(_) => store
|
||||
.data_dir()
|
||||
.join(ledger_store::root::KEYS_DIR)
|
||||
.join(ledger_store::root::LEGACY_KEY_FILE),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The one store every session shares, and whether it may still be used.
|
||||
@@ -86,10 +133,20 @@ pub struct BooksServer {
|
||||
/// store), `stopped` is set and never cleared in this process.
|
||||
/// 2. While `stopped`, every request is refused with STOPPED, before
|
||||
/// it touches the store; a restart re-verifies the journal.
|
||||
/// 3. Once `close` begins, no request starts running (each is refused
|
||||
/// with NOT_RUN_CLOSING, untouched), and `close` returns only when
|
||||
/// every request that did start has finished its blocking work,
|
||||
/// whether or not anyone still waits for its answer.
|
||||
/// ```
|
||||
pub struct SharedStore {
|
||||
store: tokio::sync::Mutex<Store>,
|
||||
stopped: std::sync::atomic::AtomicBool,
|
||||
/// Closing: no new request runs (INVARIANT 3).
|
||||
closing: std::sync::atomic::AtomicBool,
|
||||
/// Requests running (from before their blocking task to its end).
|
||||
running: std::sync::atomic::AtomicUsize,
|
||||
/// Woken whenever one ends.
|
||||
ended: tokio::sync::Notify,
|
||||
/// The books' base currency, fixed at their creation: read without
|
||||
/// the lock (the connect text names it).
|
||||
currency: Currency,
|
||||
@@ -102,9 +159,31 @@ impl SharedStore {
|
||||
currency: store.base_currency(),
|
||||
store: tokio::sync::Mutex::new(store),
|
||||
stopped: std::sync::atomic::AtomicBool::new(false),
|
||||
closing: std::sync::atomic::AtomicBool::new(false),
|
||||
running: std::sync::atomic::AtomicUsize::new(0),
|
||||
ended: tokio::sync::Notify::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Stop new requests from running (INVARIANT 3), and wait until every
|
||||
/// one already running has ended: its blocking task finished, even if
|
||||
/// its client hung up.
|
||||
pub async fn close(&self) {
|
||||
self.closing
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
loop {
|
||||
let ended = self.ended.notified();
|
||||
if self.running.load(std::sync::atomic::Ordering::SeqCst) == 0 {
|
||||
return;
|
||||
}
|
||||
ended.await;
|
||||
}
|
||||
}
|
||||
|
||||
fn closing(&self) -> bool {
|
||||
self.closing.load(std::sync::atomic::Ordering::SeqCst)
|
||||
}
|
||||
|
||||
fn stopped(&self) -> bool {
|
||||
self.stopped.load(std::sync::atomic::Ordering::SeqCst)
|
||||
}
|
||||
@@ -179,7 +258,15 @@ where
|
||||
if shared.stopped() {
|
||||
return Err(rmcp::ErrorData::internal_error(STOPPED, None));
|
||||
}
|
||||
// SharedStore INVARIANT 3: counted before the closing check, so a
|
||||
// close never misses it; the count rides in the blocking task, which
|
||||
// runs to its end even when this future is dropped.
|
||||
let running = Running::new(shared);
|
||||
if shared.closing() {
|
||||
return Err(not_run_closing());
|
||||
}
|
||||
let joined = tokio::task::spawn_blocking(move || {
|
||||
let _running = running;
|
||||
struct Unpark(std::thread::Thread);
|
||||
impl std::task::Wake for Unpark {
|
||||
fn wake(self: Arc<Self>) {
|
||||
@@ -205,6 +292,43 @@ where
|
||||
})
|
||||
}
|
||||
|
||||
/// A request running, counted in its store (SharedStore INVARIANT 3).
|
||||
struct Running(Arc<SharedStore>);
|
||||
|
||||
impl Running {
|
||||
fn new(shared: &Arc<SharedStore>) -> Self {
|
||||
shared
|
||||
.running
|
||||
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
Self(shared.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Running {
|
||||
fn drop(&mut self) {
|
||||
self.0
|
||||
.running
|
||||
.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
|
||||
self.0.ended.notify_waiters();
|
||||
}
|
||||
}
|
||||
|
||||
/// The JSON-RPC error code of a request refused because the books are
|
||||
/// closing: it didn't run, so a client may send it again once they are
|
||||
/// open (the agents' bridge does).
|
||||
pub const NOT_RUN_CLOSING_CODE: i32 = -32050;
|
||||
|
||||
/// What a request refused while closing is told.
|
||||
pub const NOT_RUN_CLOSING: &str = "the books are closing: this call wasn't run";
|
||||
|
||||
fn not_run_closing() -> rmcp::ErrorData {
|
||||
rmcp::ErrorData::new(
|
||||
rmcp::model::ErrorCode(NOT_RUN_CLOSING_CODE),
|
||||
NOT_RUN_CLOSING,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub use crate::settings::CashRoles;
|
||||
|
||||
@@ -403,9 +527,17 @@ impl BooksServer {
|
||||
..Settings::default()
|
||||
}))),
|
||||
import_roots: Arc::new(ImportRoots::defaults()),
|
||||
layout: Arc::new(Layout::BooksDir),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve a book kept in the data root (`Layout::Root`) rather than
|
||||
/// in a books dir.
|
||||
pub fn with_layout(mut self, layout: Layout) -> Self {
|
||||
self.layout = Arc::new(layout);
|
||||
self
|
||||
}
|
||||
|
||||
/// The folders `books.import_local_file` may read from (local_files.rs).
|
||||
pub fn with_import_roots(mut self, roots: ImportRoots) -> Self {
|
||||
self.import_roots = Arc::new(roots);
|
||||
@@ -727,9 +859,10 @@ impl BooksServer {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The TIN vault's directory: `vault/` in the books dir (git-ignored).
|
||||
fn vault_dir(store: &Store) -> std::path::PathBuf {
|
||||
books_dir(store).join("vault")
|
||||
/// The TIN vault's directory: `vault/` in the books dir (git-ignored),
|
||||
/// or in the book's own folder in the data root.
|
||||
fn vault_dir(&self, store: &Store) -> std::path::PathBuf {
|
||||
self.layout.vault_dir(store)
|
||||
}
|
||||
|
||||
/// What is never read through a path, even inside an import root
|
||||
@@ -739,7 +872,7 @@ impl BooksServer {
|
||||
/// the rest of the books dir.
|
||||
fn protected(&self, store: &Store) -> Protected {
|
||||
Protected::of(
|
||||
&places(store, &self.settings()),
|
||||
&places(store, &self.settings(), &self.layout),
|
||||
&self.import_roots.usable(),
|
||||
)
|
||||
}
|
||||
@@ -757,7 +890,7 @@ impl BooksServer {
|
||||
file_name: &str,
|
||||
) -> Result<(), String> {
|
||||
tin_files::refuse(
|
||||
&Self::vault_dir(store),
|
||||
&self.vault_dir(store),
|
||||
bytes,
|
||||
mime_type,
|
||||
file_name,
|
||||
@@ -809,7 +942,7 @@ impl BooksServer {
|
||||
/// the books (outside_books) and outside every import root, so it can
|
||||
/// never be filed back in as a receipt and read out again.
|
||||
fn tin_file_dest(&self, store: &Store, path: &str) -> Result<std::path::PathBuf, String> {
|
||||
let dest = outside_books(store, path)?;
|
||||
let dest = outside_books(&self.layout.books_dir(store), path)?;
|
||||
self.outside_import_roots(&dest)?;
|
||||
Ok(dest)
|
||||
}
|
||||
@@ -854,8 +987,17 @@ impl BooksServer {
|
||||
/// Record a file read or written with full TINs (tin_files.rs
|
||||
/// INVARIANT 2), before it is written out: every session, and every
|
||||
/// later server for these books, refuses it.
|
||||
fn wrote_tins(store: &Store, bytes: &[u8]) -> Result<(), String> {
|
||||
tin_files::record(&Self::vault_dir(store), bytes)
|
||||
fn wrote_tins(&self, store: &Store, bytes: &[u8]) -> Result<(), String> {
|
||||
// The record goes in the vault's folder: in the data root, made
|
||||
// private first, as the vault itself makes it.
|
||||
if let Layout::Root(_) = *self.layout {
|
||||
ledger_store::root::private_book_folder(
|
||||
store.data_dir(),
|
||||
ledger_store::root::VAULT_DIR,
|
||||
)
|
||||
.map_err(|e| format!("the TIN vault can't be written: {e}"))?;
|
||||
}
|
||||
tin_files::record(&self.vault_dir(store), bytes)
|
||||
}
|
||||
|
||||
/// Open the TIN vault with the key file in force (`[vault] key_file`,
|
||||
@@ -865,13 +1007,23 @@ impl BooksServer {
|
||||
/// records it unlocks. Storing into books kept in git is refused
|
||||
/// unless their `.gitignore` lists `vault/`.
|
||||
fn open_vault(&self, store: &Store, create: bool) -> Result<ledger_vault::Vault, String> {
|
||||
let key = self.settings().key_file();
|
||||
if is_inside(&books_dir(store), &key) {
|
||||
let key = self.layout.key_file(store, &self.settings());
|
||||
// In the data root the key is the book's own `keys/legacy.key`;
|
||||
// its folder and the vault's are made private when a TIN is first
|
||||
// stored.
|
||||
if let Layout::Root(_) = *self.layout {
|
||||
if create {
|
||||
for folder in [ledger_store::root::KEYS_DIR, "vault"] {
|
||||
ledger_store::root::private_book_folder(store.data_dir(), folder)
|
||||
.map_err(|e| format!("the TIN vault can't store anything yet: {e}"))?;
|
||||
}
|
||||
}
|
||||
} else if is_inside(&books_dir(store), &key) {
|
||||
return Err(KEY_IN_BOOKS.to_string());
|
||||
}
|
||||
// Writing: a git repository must ignore the vault, or its records
|
||||
// would be committed (and could never be purged).
|
||||
let books = books_dir(store);
|
||||
let books = self.layout.books_dir(store);
|
||||
if create && books.join(".git").exists() {
|
||||
let ignored = std::fs::read_to_string(books.join(".gitignore"))
|
||||
.unwrap_or_default()
|
||||
@@ -882,7 +1034,7 @@ impl BooksServer {
|
||||
}
|
||||
}
|
||||
// No message names the key file or the books' folders.
|
||||
ledger_vault::Vault::open(&Self::vault_dir(store), &key, create).map_err(|e| match e {
|
||||
ledger_vault::Vault::open(&self.vault_dir(store), &key, create).map_err(|e| match e {
|
||||
ledger_vault::Error::NoKey(_) => "the TIN vault is locked: its key file is missing (restore it from the human's password manager, or set [vault] key_file in the settings)".to_string(),
|
||||
ledger_vault::Error::BadKey(_) => "the TIN vault's key file is not a vault key (64 hex digits): restore it from the human's password manager".to_string(),
|
||||
ledger_vault::Error::Io(e) => format!("the TIN vault can't be read: {}", e.kind()),
|
||||
@@ -1732,13 +1884,23 @@ fn known_tin_last4(store: &Store) -> Vec<u32> {
|
||||
/// Where these books keep what an import must never read
|
||||
/// (local_files.rs): the data dir, the books dir, the vault, the key file
|
||||
/// in force and the default one, and the home folder.
|
||||
fn places(store: &Store, settings: &Settings) -> Places {
|
||||
fn places(store: &Store, settings: &Settings, layout: &Layout) -> Places {
|
||||
Places {
|
||||
data_dir: store.data_dir().to_path_buf(),
|
||||
books_dir: books_dir(store),
|
||||
vault_dir: BooksServer::vault_dir(store),
|
||||
key_files: vec![settings.key_file(), ledger_vault::default_key_file()],
|
||||
home: std::env::var_os("HOME").map(std::path::PathBuf::from),
|
||||
books_dir: layout.books_dir(store),
|
||||
vault_dir: layout.vault_dir(store),
|
||||
key_files: vec![
|
||||
layout.key_file(store, settings),
|
||||
settings.key_file(),
|
||||
ledger_vault::default_key_file(),
|
||||
],
|
||||
// In the data root the home comes from the account's record, as
|
||||
// the root's own does; never only from the environment.
|
||||
home: match layout {
|
||||
Layout::Root(_) => ledger_store::root::account_home().ok(),
|
||||
Layout::BooksDir => None,
|
||||
}
|
||||
.or_else(|| std::env::var_os("HOME").map(std::path::PathBuf::from)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1750,9 +1912,10 @@ fn places(store: &Store, settings: &Settings) -> Places {
|
||||
pub fn guard_import_roots(
|
||||
store: &Store,
|
||||
settings: &Settings,
|
||||
layout: &Layout,
|
||||
roots: ImportRoots,
|
||||
) -> Result<ImportRoots, String> {
|
||||
let places = places(store, settings);
|
||||
let places = places(store, settings, layout);
|
||||
let explicit = roots.explicit();
|
||||
let (roots, refused) = roots.guard(places.home.as_deref(), &places.keep_out());
|
||||
if let Some((root, why)) = refused.first() {
|
||||
@@ -1775,12 +1938,12 @@ fn is_inside(dir: &std::path::Path, path: &std::path::Path) -> bool {
|
||||
|
||||
/// Where a tool may write a file holding a full TIN: a new file, in a
|
||||
/// folder that exists, outside the books dir (which git commits).
|
||||
fn outside_books(store: &Store, path: &str) -> Result<std::path::PathBuf, String> {
|
||||
fn outside_books(books: &std::path::Path, path: &str) -> Result<std::path::PathBuf, String> {
|
||||
let p = std::path::PathBuf::from(path.trim());
|
||||
if path.trim().is_empty() {
|
||||
return Err("give a path to write to".into());
|
||||
}
|
||||
if is_inside(&books_dir(store), &p) {
|
||||
if is_inside(books, &p) {
|
||||
return Err(INSIDE_BOOKS.to_string());
|
||||
}
|
||||
match p.parent() {
|
||||
@@ -1804,7 +1967,7 @@ const EXISTS: &str = "a file is there already: name a new file";
|
||||
/// folder. A new file, in a folder that exists; with TINs, outside the
|
||||
/// books dir (which git commits, `exports/` included).
|
||||
fn package_dest(
|
||||
store: &Store,
|
||||
books: &std::path::Path,
|
||||
to: &str,
|
||||
file_name: &str,
|
||||
tins: bool,
|
||||
@@ -1827,7 +1990,7 @@ fn package_dest(
|
||||
));
|
||||
};
|
||||
if tins {
|
||||
return outside_books(store, &dest.to_string_lossy()).map_err(|e| {
|
||||
return outside_books(books, &dest.to_string_lossy()).map_err(|e| {
|
||||
format!("{e} (the CPA package holds full TINs; save it outside the books)")
|
||||
});
|
||||
}
|
||||
@@ -4880,7 +5043,7 @@ impl BooksServer {
|
||||
let dest = if p.include_tins {
|
||||
self.tin_file_dest(&store, path)?
|
||||
} else {
|
||||
outside_books(&store, path)?
|
||||
outside_books(&self.layout.books_dir(&store), path)?
|
||||
};
|
||||
let mut csv =
|
||||
String::from("vendor_id,name,form_1099,paid,reportable,w9_on_file,tin_last4");
|
||||
@@ -4907,7 +5070,7 @@ impl BooksServer {
|
||||
csv.push('\n');
|
||||
}
|
||||
if p.include_tins {
|
||||
Self::wrote_tins(&store, csv.as_bytes())?;
|
||||
self.wrote_tins(&store, csv.as_bytes())?;
|
||||
}
|
||||
write_private_new(&dest, csv.as_bytes())?;
|
||||
Some(dest.display().to_string())
|
||||
@@ -6967,7 +7130,7 @@ impl BooksServer {
|
||||
.as_ref()
|
||||
.is_some_and(|t| party_tin.as_ref().is_some_and(|pt| pt.last4 != t.last4()));
|
||||
let mut tin_facts: Option<ledger_core::TinFacts> = None;
|
||||
let vault_dir = Self::vault_dir(&store);
|
||||
let vault_dir = self.vault_dir(&store);
|
||||
let (held_tin, held_w9) =
|
||||
ledger_vault::records_held(&vault_dir, id).map_err(|e| e.to_string())?;
|
||||
let facts = match facts {
|
||||
@@ -7028,7 +7191,7 @@ impl BooksServer {
|
||||
// never filed into the books (tin_files.rs INVARIANT
|
||||
// 1). Only now: every check has passed.
|
||||
if let Some(w) = &w9_file {
|
||||
Self::wrote_tins(&store, &w.bytes)?;
|
||||
self.wrote_tins(&store, &w.bytes)?;
|
||||
}
|
||||
let tin_hash = match &tin {
|
||||
Some(t) => Some(vault.put_tin(id, t).map_err(|e| e.to_string())?),
|
||||
@@ -7044,10 +7207,14 @@ impl BooksServer {
|
||||
None => w9_hash,
|
||||
}
|
||||
} else if held_tin {
|
||||
ledger_vault::Vault::open(&vault_dir, &self.settings().key_file(), false)
|
||||
.ok()
|
||||
.and_then(|v| v.record_hash(id).ok().flatten())
|
||||
.or_else(|| existing.as_ref().and_then(|e| e.vault_record.clone()))
|
||||
ledger_vault::Vault::open(
|
||||
&vault_dir,
|
||||
&self.layout.key_file(&store, &self.settings()),
|
||||
false,
|
||||
)
|
||||
.ok()
|
||||
.and_then(|v| v.record_hash(id).ok().flatten())
|
||||
.or_else(|| existing.as_ref().and_then(|e| e.vault_record.clone()))
|
||||
} else if held_w9 {
|
||||
existing.as_ref().and_then(|e| e.vault_record.clone())
|
||||
} else {
|
||||
@@ -7215,7 +7382,7 @@ impl BooksServer {
|
||||
"the TIN vault holds no W-9 file for party {id}: store one with books.set_w9 w9_file_path"
|
||||
)
|
||||
})?;
|
||||
Self::wrote_tins(&store, &file.bytes)?;
|
||||
self.wrote_tins(&store, &file.bytes)?;
|
||||
write_private_new(&dest, &file.bytes)?;
|
||||
Ok(Json(GetW9Out {
|
||||
party_id: id,
|
||||
@@ -7270,7 +7437,7 @@ impl BooksServer {
|
||||
let mut store = self.lock()?;
|
||||
let id = resolve_party(&store, &p.party)?;
|
||||
let party = store.ledger().get_party(id).expect("resolved").clone();
|
||||
let vault_dir = Self::vault_dir(&store);
|
||||
let vault_dir = self.vault_dir(&store);
|
||||
let (held_tin, held_w9) =
|
||||
ledger_vault::records_held(&vault_dir, id).map_err(|e| e.to_string())?;
|
||||
let recorded = party.w9.as_ref().is_some_and(|f| f.vault_record.is_some())
|
||||
@@ -8279,7 +8446,15 @@ impl BooksServer {
|
||||
let dest = p
|
||||
.save_to
|
||||
.as_deref()
|
||||
.map(|to| package_dest(&store, to, &file_name, p.include_tins))
|
||||
.map(|to| {
|
||||
package_dest(
|
||||
&self.layout.books_dir(&store),
|
||||
to,
|
||||
&file_name,
|
||||
// In the data root nothing is ever written, TINs or not.
|
||||
p.include_tins || matches!(*self.layout, Layout::Root(_)),
|
||||
)
|
||||
})
|
||||
.transpose()?;
|
||||
if p.include_tins
|
||||
&& let Some(d) = &dest
|
||||
@@ -8653,7 +8828,7 @@ impl BooksServer {
|
||||
files.push(k1);
|
||||
}
|
||||
let seq = store.journal_seq();
|
||||
let vault_dir = Self::vault_dir(&store);
|
||||
let vault_dir = self.vault_dir(&store);
|
||||
drop(store);
|
||||
files.push((
|
||||
"general-journal.iif".into(),
|
||||
@@ -9385,6 +9560,11 @@ impl ServerHandler for BooksServer {
|
||||
request: rmcp::model::CallToolRequestParams,
|
||||
context: RequestContext<RoleServer>,
|
||||
) -> Result<rmcp::model::CallToolResponse, rmcp::ErrorData> {
|
||||
// Closing: refused as a protocol error, so a client knows it
|
||||
// didn't run (SharedStore INVARIANT 3).
|
||||
if self.store.closing() {
|
||||
return Err(not_run_closing());
|
||||
}
|
||||
// Pick up an edited settings file before the tool runs.
|
||||
self.live().refresh();
|
||||
let timezone = self.settings().timezone.clone();
|
||||
@@ -9402,6 +9582,9 @@ impl ServerHandler for BooksServer {
|
||||
// plain refusal, redacted like every other.
|
||||
let result = match result {
|
||||
Ok(out) => out,
|
||||
// Not run because the books are closing: a protocol error, so
|
||||
// the client knows (SharedStore INVARIANT 3).
|
||||
Err(e) if e.code.0 == NOT_RUN_CLOSING_CODE => return Err(e),
|
||||
Err(e) => Ok(rmcp::model::CallToolResponse::Complete(
|
||||
rmcp::model::CallToolResult::error(vec![rmcp::model::ContentBlock::text(
|
||||
e.message.to_string(),
|
||||
@@ -9421,19 +9604,14 @@ impl ServerHandler for BooksServer {
|
||||
.sync(store.ledger().settings(), self.store.currency.code());
|
||||
}
|
||||
self.live().refresh();
|
||||
ServerConfig::new(
|
||||
ServerCapabilities::builder()
|
||||
.enable_tools()
|
||||
.enable_resources()
|
||||
.build(),
|
||||
)
|
||||
// The rules in brief, with the policy these books actually use;
|
||||
// the rest is in the handbook.
|
||||
.with_instructions(handbook::connect_text(&self.settings(), self.currency()))
|
||||
.with_server_info(Implementation::new(
|
||||
"wanderbooks",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
))
|
||||
ServerConfig::new(crate::opening::capabilities())
|
||||
// The rules in brief, with the policy these books actually use;
|
||||
// the rest is in the handbook.
|
||||
.with_instructions(handbook::connect_text(&self.settings(), self.currency()))
|
||||
.with_server_info(Implementation::new(
|
||||
"wanderbooks",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
))
|
||||
}
|
||||
|
||||
async fn list_resources(
|
||||
|
||||
@@ -30,6 +30,9 @@
|
||||
//! base currency, and a vault key file given on the command line are
|
||||
//! fixed when the server starts (the key file given wins over any
|
||||
//! file's [vault] key_file).
|
||||
//! 6. A book in the data root (plan 3.4) has no settings file at all:
|
||||
//! its settings are its own, the defaults until the first is set,
|
||||
//! and no file is ever read for it.
|
||||
//! ```
|
||||
|
||||
use crate::policy::ReceiptPolicy;
|
||||
@@ -178,6 +181,9 @@ pub struct Live {
|
||||
/// Whether the settings files may be read: false once the books keep
|
||||
/// their own (INVARIANT 1).
|
||||
read_files: bool,
|
||||
/// A book in the data root: its settings are only ever its own
|
||||
/// (INVARIANT 6).
|
||||
own: bool,
|
||||
}
|
||||
|
||||
/// What books.settings reports about the files.
|
||||
@@ -212,6 +218,26 @@ impl Live {
|
||||
journal_seq: None,
|
||||
vault_key_file: None,
|
||||
read_files: false,
|
||||
own: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// For a book in the data root (INVARIANT 6): no settings file, now
|
||||
/// or later; `sync` brings the books' settings in once they hold any.
|
||||
pub fn own() -> Self {
|
||||
Self {
|
||||
current: Arc::new(Settings {
|
||||
journaled: true,
|
||||
..Settings::default()
|
||||
}),
|
||||
source: None,
|
||||
seen: (None, None),
|
||||
error: None,
|
||||
loads: 0,
|
||||
journal_seq: None,
|
||||
vault_key_file: None,
|
||||
read_files: false,
|
||||
own: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,6 +255,7 @@ impl Live {
|
||||
journal_seq: None,
|
||||
vault_key_file: None,
|
||||
read_files: true,
|
||||
own: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -249,6 +276,7 @@ impl Live {
|
||||
journal_seq: None,
|
||||
vault_key_file: None,
|
||||
read_files: false,
|
||||
own: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,7 +324,7 @@ impl Live {
|
||||
|
||||
/// Whether the settings in force are the books' own.
|
||||
pub fn is_journaled(&self) -> bool {
|
||||
self.journal_seq.is_some() || (self.source.is_some() && !self.read_files)
|
||||
self.own || self.journal_seq.is_some() || (self.source.is_some() && !self.read_files)
|
||||
}
|
||||
|
||||
/// The settings in force.
|
||||
|
||||
@@ -1166,7 +1166,9 @@ impl BooksServer {
|
||||
async fn export_settings(&self) -> Result<Json<ExportSettingsOut>, String> {
|
||||
let store = self.lock()?;
|
||||
let s = store.ledger().settings();
|
||||
if !s.is_journaled() {
|
||||
// Books in the data root read no file: theirs are the defaults
|
||||
// until the first is set.
|
||||
if !s.is_journaled() && !self.live().status().journaled {
|
||||
return Err(
|
||||
"these books still read their settings file, which is the copy: move it in with books.import_settings first"
|
||||
.into(),
|
||||
|
||||
+196
-36
@@ -58,13 +58,19 @@ pub const BOOKS_DIR: &str = "books";
|
||||
pub const RUN_DIR: &str = "run";
|
||||
/// Old copies moved aside at adoption, never deleted (plan 5.5).
|
||||
pub const LEGACY_DIR: &str = "legacy";
|
||||
/// A book's TIN vault folder, in its own folder.
|
||||
pub const VAULT_DIR: &str = "vault";
|
||||
/// A book's key folder, in its own folder.
|
||||
pub const KEYS_DIR: &str = "keys";
|
||||
/// The v1 vault key, in a book's `keys/` (P2 to P7).
|
||||
pub const LEGACY_KEY_FILE: &str = "legacy.key";
|
||||
/// Held while books are created or adopted, so only one is made at a time.
|
||||
const BOOKS_LOCK: &str = "books.lock";
|
||||
/// Staging folders under `books/` start so; none is a valid book id.
|
||||
const STAGING_PREFIX: &str = ".adopt-";
|
||||
|
||||
/// How the root is named in messages (INVARIANT 6).
|
||||
const THE_ROOT: &str = "the books' private folder";
|
||||
pub const THE_ROOT: &str = "the books' private folder";
|
||||
|
||||
/// Why a root or book operation was refused. No message names the root or
|
||||
/// a path inside it (INVARIANT 6).
|
||||
@@ -244,6 +250,91 @@ pub fn default_root(home: &Path) -> PathBuf {
|
||||
platform_data_dir(home).join(ROOT_NAME)
|
||||
}
|
||||
|
||||
/// Whether `path` is a data root or inside one (any folder above it, or
|
||||
/// it, holds the marker `setup` leaves). Books there belong to the books
|
||||
/// server alone (plan 3.1: `dev-stdio` refuses them).
|
||||
pub fn inside_a_root(path: &Path) -> bool {
|
||||
// From here (a path that is already absolute stays as it is).
|
||||
let path = std::env::current_dir()
|
||||
.map(|here| here.join(path))
|
||||
.unwrap_or_else(|_| path.to_path_buf());
|
||||
// Where the deepest folder that exists really is: a folder not made
|
||||
// yet holds no marker, and a link counts as where it leads.
|
||||
let Some(existing) = path.ancestors().find(|dir| dir.exists()) else {
|
||||
return false;
|
||||
};
|
||||
let real = std::fs::canonicalize(existing).unwrap_or_else(|_| existing.to_path_buf());
|
||||
real.ancestors().any(|dir| dir.join(MARKER_FILE).is_file())
|
||||
}
|
||||
|
||||
/// A folder directly in a book's own folder (`keys/`, `vault/`): made
|
||||
/// 0700 if missing, made 0700 again if it is ours but others could enter
|
||||
/// it; refused if it is a symlink or belongs to another user. The error
|
||||
/// names no path (INVARIANT 6).
|
||||
pub fn private_book_folder(book_dir: &Path, name: &str) -> Result<PathBuf, RootError> {
|
||||
let dir = book_dir.join(name);
|
||||
match std::fs::symlink_metadata(&dir) {
|
||||
Ok(meta) => {
|
||||
let open = check_private_dir(&meta, true, "a folder inside the books")?;
|
||||
tighten_if(&dir, open)?;
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
make_private_dir(&dir)?;
|
||||
sync_dir(book_dir)?;
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// The longest socket path used as it is: a Unix socket's address holds
|
||||
/// 104 bytes on macOS and 108 on Linux, the terminating zero included.
|
||||
const SOCKET_PATH_MAX: usize = 100;
|
||||
|
||||
/// The path to a socket named `name` in `dir`, short enough for the
|
||||
/// system: the path itself when it fits; else, on Linux, the same place
|
||||
/// reached through `/proc/self/fd` and a handle on `dir` kept open while
|
||||
/// this lives (it is good only in this process). Elsewhere a path too
|
||||
/// long is refused, saying so plainly (no path named).
|
||||
#[derive(Debug)]
|
||||
pub struct SocketPath {
|
||||
path: PathBuf,
|
||||
_dir: Option<std::fs::File>,
|
||||
}
|
||||
|
||||
impl SocketPath {
|
||||
/// The socket `name` in `dir`.
|
||||
pub fn new(dir: &Path, name: &str) -> std::io::Result<Self> {
|
||||
let full = dir.join(name);
|
||||
if full.as_os_str().len() <= SOCKET_PATH_MAX {
|
||||
return Ok(Self {
|
||||
path: full,
|
||||
_dir: None,
|
||||
});
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::os::fd::AsRawFd;
|
||||
let handle = std::fs::File::open(dir)?;
|
||||
let path = PathBuf::from(format!("/proc/self/fd/{}/{name}", handle.as_raw_fd()));
|
||||
Ok(Self {
|
||||
path,
|
||||
_dir: Some(handle),
|
||||
})
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"the books' private folder's path is too long for its sockets: choose a shorter place with `wanderbooks setup --root`",
|
||||
))
|
||||
}
|
||||
|
||||
/// The path to bind or connect to.
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
}
|
||||
|
||||
/// The data root.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Root {
|
||||
@@ -422,6 +513,14 @@ impl Root {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// The root's `run/` folder (its sockets and locks), made 0700 if
|
||||
/// missing and refused if it isn't private.
|
||||
pub fn run_dir(&self) -> Result<PathBuf, RootError> {
|
||||
self.private_subdir(RUN_DIR, false)
|
||||
.map(|(dir, _)| dir)
|
||||
.map_err(|e| self.hide(e))
|
||||
}
|
||||
|
||||
/// The folder a book lives in.
|
||||
pub fn book_dir(&self, id: &BookId) -> PathBuf {
|
||||
self.path.join(BOOKS_DIR).join(id.as_str())
|
||||
@@ -562,22 +661,7 @@ impl Root {
|
||||
if std::fs::symlink_metadata(&dest).is_ok() {
|
||||
return Err(RootError::Exists(id.to_string()));
|
||||
}
|
||||
let source = std::fs::canonicalize(source).map_err(|e| {
|
||||
RootError::Adopt(format!("can't read the books at {}: {e}", source.display()))
|
||||
})?;
|
||||
let root = std::fs::canonicalize(&self.path)?;
|
||||
if source.starts_with(&root) || root.starts_with(&source) {
|
||||
return Err(RootError::Adopt(format!(
|
||||
"those books are inside {THE_ROOT} (or hold it): adopt books from outside it"
|
||||
)));
|
||||
}
|
||||
let journal_path = source.join(JOURNAL_FILE);
|
||||
if !journal_path.is_file() {
|
||||
return Err(RootError::Adopt(format!(
|
||||
"there are no books at {}: it has no {JOURNAL_FILE}",
|
||||
source.display()
|
||||
)));
|
||||
}
|
||||
let source = self.adoptable(source)?;
|
||||
for (what, path) in [
|
||||
("vault folder", &options.vault_dir),
|
||||
("vault key", &options.vault_key),
|
||||
@@ -586,8 +670,7 @@ impl Root {
|
||||
&& !path.exists()
|
||||
{
|
||||
return Err(RootError::Adopt(format!(
|
||||
"the {what} given, {}, isn't there: nothing was adopted",
|
||||
path.display()
|
||||
"the {what} given isn't there: nothing was adopted"
|
||||
)));
|
||||
}
|
||||
}
|
||||
@@ -595,16 +678,17 @@ impl Root {
|
||||
// writes it while it is copied.
|
||||
let (_source_lock, source_lock_created) = lock_source(&source)?;
|
||||
// Step 2: verify the chain and record the head.
|
||||
let raw = std::fs::read(&journal_path)?;
|
||||
if raw.last() != Some(&b'\n') {
|
||||
return Err(RootError::Adopt(
|
||||
"the source's last journal line is cut off: open the books once with the server \
|
||||
that wrote them (it repairs a cut-off line), then adopt them"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
let (lines, _format) = crate::journal::verify_journal_and_format(&raw)?;
|
||||
let (raw, lines) = read_source_journal(&source)?;
|
||||
let head = head_of(&lines);
|
||||
if let Some(expected) = &options.expect_head
|
||||
&& *expected != head
|
||||
{
|
||||
return Err(RootError::Adopt(format!(
|
||||
"those books changed since the preview (they are now at event {}): nothing was \
|
||||
adopted; preview them again",
|
||||
head.0
|
||||
)));
|
||||
}
|
||||
let keeps_settings = lines.iter().any(|l| l.line_type == "settings.set");
|
||||
if !keeps_settings && options.settings.is_none() {
|
||||
return Err(RootError::Adopt(
|
||||
@@ -634,14 +718,14 @@ impl Root {
|
||||
let copied = copy_blobs(&source.join(blob_dir), &staging.join(blob_dir))?;
|
||||
// Step 5: the vault's records and the v1 key.
|
||||
let vault_files = match &options.vault_dir {
|
||||
Some(dir) => copy_flat_private(dir, &staging.join("vault"))?,
|
||||
Some(dir) => copy_flat_private(dir, &staging.join(VAULT_DIR))?,
|
||||
None => 0,
|
||||
};
|
||||
let key_copied = match &options.vault_key {
|
||||
Some(key) => {
|
||||
let keys = staging.join("keys");
|
||||
let keys = staging.join(KEYS_DIR);
|
||||
make_private_dir(&keys)?;
|
||||
write_private_file(&keys.join("legacy.key"), &std::fs::read(key)?)?;
|
||||
write_private_file(&keys.join(LEGACY_KEY_FILE), &std::fs::read(key)?)?;
|
||||
sync_dir(&keys)?;
|
||||
true
|
||||
}
|
||||
@@ -708,6 +792,79 @@ impl Root {
|
||||
}
|
||||
}
|
||||
|
||||
impl Root {
|
||||
/// The source of an adoption, made absolute: books outside the root
|
||||
/// (and not holding it) with a journal.
|
||||
fn adoptable(&self, source: &Path) -> Result<PathBuf, RootError> {
|
||||
let source = std::fs::canonicalize(source)
|
||||
.map_err(|e| RootError::Adopt(format!("can't read the books given: {}", e.kind())))?;
|
||||
let root = std::fs::canonicalize(&self.path)?;
|
||||
if source.starts_with(&root) || root.starts_with(&source) {
|
||||
return Err(RootError::Adopt(format!(
|
||||
"those books are inside {THE_ROOT} (or hold it): adopt books from outside it"
|
||||
)));
|
||||
}
|
||||
if !source.join(JOURNAL_FILE).is_file() {
|
||||
return Err(RootError::Adopt(format!(
|
||||
"there are no books there: it has no {JOURNAL_FILE}"
|
||||
)));
|
||||
}
|
||||
Ok(source)
|
||||
}
|
||||
|
||||
/// What adopting `source` would start from: its head, its base
|
||||
/// currency, and whether it keeps its own settings, read under the
|
||||
/// checks [`Self::adopt`] makes, writing nothing (not even a lock
|
||||
/// file). The adoption itself reads it all again under the lock.
|
||||
pub fn adopt_preview(&self, source: &Path) -> Result<SourcePreview, RootError> {
|
||||
(|| {
|
||||
let source = self.adoptable(source)?;
|
||||
// Held for writing now: adoption would be refused, so is this.
|
||||
if let Ok(lock) = std::fs::File::open(source.join(crate::store::LOCK_FILE))
|
||||
&& let Err(std::fs::TryLockError::WouldBlock) = lock.try_lock_shared()
|
||||
{
|
||||
return Err(RootError::from(StoreError::Locked(
|
||||
"another wanderbooks process has those books open for writing (an agent \
|
||||
session, or its MCP server); close it first"
|
||||
.into(),
|
||||
)));
|
||||
}
|
||||
let (_, lines) = read_source_journal(&source)?;
|
||||
Ok(SourcePreview {
|
||||
head: head_of(&lines),
|
||||
currency: crate::store::journal_currency(&lines)?,
|
||||
keeps_settings: lines.iter().any(|l| l.line_type == "settings.set"),
|
||||
})
|
||||
})()
|
||||
.map_err(|e| self.hide(e))
|
||||
}
|
||||
}
|
||||
|
||||
/// The source's journal, whole and verified: its bytes and lines.
|
||||
fn read_source_journal(source: &Path) -> Result<(Vec<u8>, Vec<crate::JournalLine>), RootError> {
|
||||
let raw = std::fs::read(source.join(JOURNAL_FILE))?;
|
||||
if raw.last() != Some(&b'\n') {
|
||||
return Err(RootError::Adopt(
|
||||
"the source's last journal line is cut off: open the books once with the server \
|
||||
that wrote them (it repairs a cut-off line), then adopt them"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
let (lines, _format) = crate::journal::verify_journal_and_format(&raw)?;
|
||||
Ok((raw, lines))
|
||||
}
|
||||
|
||||
/// What [`Root::adopt_preview`] found.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SourcePreview {
|
||||
/// The source's head, `(seq, hash)`.
|
||||
pub head: (u64, String),
|
||||
/// Its base currency.
|
||||
pub currency: Currency,
|
||||
/// Whether it keeps its own settings.
|
||||
pub keeps_settings: bool,
|
||||
}
|
||||
|
||||
/// Options for [`Root::adopt`].
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct AdoptOptions {
|
||||
@@ -725,6 +882,9 @@ pub struct AdoptOptions {
|
||||
/// The v1 vault key file: copied to the book's `keys/legacy.key`.
|
||||
/// Given, it must exist.
|
||||
pub vault_key: Option<PathBuf>,
|
||||
/// The head the source must have, `(seq, hash)`, as a preview read
|
||||
/// it: if the books changed since, nothing is adopted.
|
||||
pub expect_head: Option<(u64, String)>,
|
||||
}
|
||||
|
||||
/// What [`Root::adopt`] did.
|
||||
@@ -779,11 +939,11 @@ fn lock_source(source: &Path) -> Result<(std::fs::File, bool), RootError> {
|
||||
match file.try_lock() {
|
||||
Ok(()) => {}
|
||||
Err(std::fs::TryLockError::WouldBlock) => {
|
||||
return Err(RootError::from(StoreError::Locked(format!(
|
||||
"another wanderbooks process has the books in {} open for writing (an agent \
|
||||
session, or its MCP server); close it first",
|
||||
source.display()
|
||||
))));
|
||||
return Err(RootError::from(StoreError::Locked(
|
||||
"another wanderbooks process has those books open for writing (an agent \
|
||||
session, or its MCP server); close it first"
|
||||
.into(),
|
||||
)));
|
||||
}
|
||||
Err(std::fs::TryLockError::Error(e)) => return Err(e.into()),
|
||||
}
|
||||
|
||||
@@ -6235,7 +6235,7 @@ fn row_to_attachment(r: &rusqlite::Row) -> rusqlite::Result<Attachment> {
|
||||
|
||||
/// The base currency of a journal: its first line's, which must be a
|
||||
/// sound `store.created` ([`created_currency`]).
|
||||
fn journal_currency(lines: &[JournalLine]) -> Result<Currency, StoreError> {
|
||||
pub(crate) fn journal_currency(lines: &[JournalLine]) -> Result<Currency, StoreError> {
|
||||
let first = lines
|
||||
.first()
|
||||
.ok_or_else(|| StoreError::JournalCorrupt("journal has no store.created line".into()))?;
|
||||
|
||||
@@ -112,7 +112,7 @@ pub fn command_path(data_dir: &Path) -> PathBuf {
|
||||
pub fn request(data_dir: &Path, target: ViewerTarget) -> std::io::Result<ViewerCommand> {
|
||||
let path = command_path(data_dir);
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
private_folder(dir)?;
|
||||
}
|
||||
let seq = read(data_dir).map_or(1, |c| c.seq + 1);
|
||||
let cmd = ViewerCommand {
|
||||
@@ -129,6 +129,42 @@ pub fn request(data_dir: &Path, target: ViewerTarget) -> std::io::Result<ViewerC
|
||||
Ok(cmd)
|
||||
}
|
||||
|
||||
/// The folder the requests go in: made private (0700) if missing, made
|
||||
/// private again if ours; refused if it is a link or another account's
|
||||
/// (in a shared temp folder anyone could have planted one, to have a
|
||||
/// request written through it).
|
||||
fn private_folder(dir: &Path) -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
||||
if let Some(parent) = dir.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
match std::fs::symlink_metadata(dir) {
|
||||
Ok(meta) => {
|
||||
// SAFETY: geteuid has no preconditions and can't fail.
|
||||
let me = unsafe { libc::geteuid() };
|
||||
if !meta.is_dir() || meta.uid() != me {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
"the viewer's request folder isn't this account's own",
|
||||
));
|
||||
}
|
||||
// Private again, whatever it was.
|
||||
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
std::fs::create_dir(dir)?;
|
||||
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
std::fs::create_dir_all(dir)
|
||||
}
|
||||
|
||||
/// The latest request for `data_dir`, if any.
|
||||
pub fn read(data_dir: &Path) -> Option<ViewerCommand> {
|
||||
let bytes = std::fs::read(command_path(data_dir)).ok()?;
|
||||
@@ -170,6 +206,26 @@ mod tests {
|
||||
);
|
||||
// Same books, same file; other books, another.
|
||||
assert_eq!(command_path(&dir), command_path(&dir.join(".")));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let base = std::env::temp_dir().join(format!("wb-viewer-priv-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
// Made private; made private again.
|
||||
let folder = base.join("wanderbooks");
|
||||
private_folder(&folder).unwrap();
|
||||
let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode(&folder), 0o700);
|
||||
std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
private_folder(&folder).unwrap();
|
||||
assert_eq!(mode(&folder), 0o700);
|
||||
// A link planted in its place is refused.
|
||||
let link = base.join("planted");
|
||||
std::os::unix::fs::symlink(&base, &link).unwrap();
|
||||
assert!(private_folder(&link).is_err());
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
assert_ne!(command_path(&dir), command_path(&std::env::temp_dir()));
|
||||
let _ = std::fs::remove_file(command_path(&dir));
|
||||
}
|
||||
|
||||
@@ -316,6 +316,8 @@ fn adopted_books_are_the_same_books_and_the_source_is_untouched() {
|
||||
settings: None,
|
||||
vault_dir: Some(vault.clone()),
|
||||
vault_key: Some(key.clone()),
|
||||
// As a preview read it: the same head, so it goes ahead.
|
||||
expect_head: Some(source_head.clone()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -724,8 +726,16 @@ fn adoption_leaves_a_read_only_source_as_it_was() {
|
||||
drop(source_books(old.path(), true));
|
||||
let before = snapshot_files(old.path());
|
||||
chmod_tree(old.path(), 0o500, 0o400);
|
||||
// Writable again however the test ends, so its folder can go.
|
||||
struct Writable<'a>(&'a Path);
|
||||
impl Drop for Writable<'_> {
|
||||
fn drop(&mut self) {
|
||||
chmod_tree(self.0, 0o700, 0o600);
|
||||
}
|
||||
}
|
||||
let writable = Writable(old.path());
|
||||
let report = root.adopt(old.path(), &id("acme"), &AdoptOptions::default());
|
||||
chmod_tree(old.path(), 0o700, 0o600);
|
||||
drop(writable);
|
||||
let report = report.unwrap();
|
||||
assert!(!report.source_lock_created);
|
||||
assert_eq!(snapshot_files(old.path()), before, "the lock file included");
|
||||
@@ -1099,3 +1109,178 @@ fn errors_other_than_not_found_are_never_taken_for_it() {
|
||||
std::fs::set_permissions(&lock, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
assert!(root.list_books().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_adoption_preview_reads_the_source_and_writes_nothing() {
|
||||
let home = tmp("preview");
|
||||
let (root, _) = Root::setup(home.path(), None, None).unwrap();
|
||||
let old = tmp("preview-src");
|
||||
let s = source_books(old.path(), false);
|
||||
let head = s.head();
|
||||
drop(s);
|
||||
// No lock file beside the journal: the preview must not make one.
|
||||
let _ = std::fs::remove_file(old.path().join("wanderbooks.lock"));
|
||||
let before = snapshot_files(old.path());
|
||||
let found = root.adopt_preview(old.path()).unwrap();
|
||||
assert_eq!(found.head, head);
|
||||
assert_eq!(found.currency, Currency::USD);
|
||||
assert!(!found.keeps_settings);
|
||||
assert_eq!(
|
||||
snapshot_files(old.path()),
|
||||
before,
|
||||
"the preview wrote to the source"
|
||||
);
|
||||
assert!(root.list_books().unwrap().is_empty());
|
||||
// Books that keep their own settings say so.
|
||||
let kept = tmp("preview-kept");
|
||||
drop(source_books(kept.path(), true));
|
||||
assert!(root.adopt_preview(kept.path()).unwrap().keeps_settings);
|
||||
// The preview refuses what adoption refuses, naming no root.
|
||||
let book = root
|
||||
.create_book(&BookId::parse("acme").unwrap(), Currency::USD)
|
||||
.unwrap();
|
||||
drop(book);
|
||||
let root_text = root.path().display().to_string();
|
||||
for bad in [
|
||||
root.book_dir(&BookId::parse("acme").unwrap()),
|
||||
root.path().to_path_buf(),
|
||||
home.path().join("missing"),
|
||||
home.path().to_path_buf(),
|
||||
] {
|
||||
let e = root.adopt_preview(&bad).unwrap_err();
|
||||
assert!(matches!(e, RootError::Adopt(_)), "{bad:?}: {e:?}");
|
||||
assert!(!e.to_string().contains(&root_text), "{e}");
|
||||
}
|
||||
// A cut-off last line is refused, as adoption refuses it.
|
||||
let cut = tmp("preview-cut");
|
||||
drop(source_books(cut.path(), false));
|
||||
let journal = cut.path().join(JOURNAL_FILE);
|
||||
let mut raw = std::fs::read(&journal).unwrap();
|
||||
raw.pop();
|
||||
std::fs::write(&journal, raw).unwrap();
|
||||
assert!(
|
||||
matches!(root.adopt_preview(cut.path()), Err(RootError::Adopt(why)) if why.contains("cut off"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_book_folder_is_made_private_and_a_shared_one_refused() {
|
||||
let home = tmp("bookfolder");
|
||||
let (root, _) = Root::setup(home.path(), None, None).unwrap();
|
||||
drop(
|
||||
root.create_book(&BookId::parse("acme").unwrap(), Currency::USD)
|
||||
.unwrap(),
|
||||
);
|
||||
let book = root.book_dir(&BookId::parse("acme").unwrap());
|
||||
let keys = ledger_store::root::private_book_folder(&book, "keys").unwrap();
|
||||
assert_eq!(keys, book.join("keys"));
|
||||
assert_eq!(mode(&keys), 0o700);
|
||||
// Again: already there, fine.
|
||||
assert_eq!(
|
||||
ledger_store::root::private_book_folder(&book, "keys").unwrap(),
|
||||
keys
|
||||
);
|
||||
// Ours, but others could enter it: made private again.
|
||||
std::fs::set_permissions(&keys, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
ledger_store::root::private_book_folder(&book, "keys").unwrap();
|
||||
assert_eq!(mode(&keys), 0o700);
|
||||
std::os::unix::fs::symlink(home.path(), book.join("vault")).unwrap();
|
||||
assert!(ledger_store::root::private_book_folder(&book, "vault").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_run_folder_is_private() {
|
||||
let home = tmp("run");
|
||||
let (root, _) = Root::setup(home.path(), None, None).unwrap();
|
||||
let run = root.run_dir().unwrap();
|
||||
assert_eq!(run, root.path().join("run"));
|
||||
assert_eq!(mode(&run), 0o700);
|
||||
std::fs::remove_dir(&run).unwrap();
|
||||
assert_eq!(mode(&root.run_dir().unwrap()), 0o700, "remade private");
|
||||
std::fs::set_permissions(&run, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
assert!(matches!(root.run_dir(), Err(RootError::Unsafe(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inside_a_root_is_known_by_its_marker() {
|
||||
let home = tmp("inside");
|
||||
let (root, _) = Root::setup(home.path(), None, None).unwrap();
|
||||
use ledger_store::root::inside_a_root;
|
||||
assert!(inside_a_root(root.path()));
|
||||
assert!(inside_a_root(&root.path().join("books/acme")));
|
||||
assert!(inside_a_root(&root.path().join("books/not/yet/made")));
|
||||
assert!(!inside_a_root(home.path()));
|
||||
// A path relative to where the process runs is taken from there.
|
||||
let here = std::env::current_dir().unwrap();
|
||||
let up = "../".repeat(here.components().count() - 1);
|
||||
let relative = PathBuf::from(format!(
|
||||
"{up}{}",
|
||||
root.path()
|
||||
.join("books/acme")
|
||||
.display()
|
||||
.to_string()
|
||||
.trim_start_matches('/')
|
||||
));
|
||||
assert!(relative.is_relative());
|
||||
assert!(inside_a_root(&relative));
|
||||
let outside = PathBuf::from(format!(
|
||||
"{up}{}",
|
||||
home.path().display().to_string().trim_start_matches('/')
|
||||
));
|
||||
assert!(!inside_a_root(&outside));
|
||||
assert!(!inside_a_root(&home.path().join("elsewhere")));
|
||||
// Through a link, too: it is where the path leads that counts.
|
||||
let link = home.path().join("link");
|
||||
std::os::unix::fs::symlink(root.path().join("books"), &link).unwrap();
|
||||
assert!(inside_a_root(&link));
|
||||
assert!(!root.path().join(MARKER_FILE).is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn books_that_changed_since_the_preview_are_not_adopted() {
|
||||
let home = tmp("expect");
|
||||
let (root, _) = Root::setup(home.path(), None, None).unwrap();
|
||||
let old = tmp("expect-src");
|
||||
drop(source_books(old.path(), true));
|
||||
let previewed = root.adopt_preview(old.path()).unwrap().head;
|
||||
// The old server writes one more event after the preview.
|
||||
let mut s = Store::open(old.path()).unwrap();
|
||||
s.add_account("6100", "Rent", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let now = s.head();
|
||||
// While it holds them, a preview is refused as adoption would be.
|
||||
assert!(matches!(
|
||||
root.adopt_preview(old.path()),
|
||||
Err(RootError::Store(_, StoreError::Locked(_)))
|
||||
));
|
||||
drop(s);
|
||||
let before = snapshot_files(old.path());
|
||||
let e = root
|
||||
.adopt(
|
||||
old.path(),
|
||||
&id("acme"),
|
||||
&AdoptOptions {
|
||||
expect_head: Some(previewed),
|
||||
..AdoptOptions::default()
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(&e, RootError::Adopt(why) if why.contains("changed since the preview")),
|
||||
"{e:?}"
|
||||
);
|
||||
assert!(root.list_books().unwrap().is_empty());
|
||||
assert_eq!(snapshot_files(old.path()), before);
|
||||
// Previewed again, it goes ahead.
|
||||
let report = root
|
||||
.adopt(
|
||||
old.path(),
|
||||
&id("acme"),
|
||||
&AdoptOptions {
|
||||
expect_head: Some(now.clone()),
|
||||
..AdoptOptions::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(report.source_head, now);
|
||||
}
|
||||
Reference in new issue
Block a user