Files
wanderbooks/crates/ledger-host/src/commands.rs
T
swaitsandClaude Opus 5.5 d24653558c
check / check (push) Canceled after 0s
Tokens: one keyring item per token, read back; the bridge says a locked keyring; token check
On the live books a reconnected bridge got no token from a keyring that
couldn't be read, was told to "make a token", and after
`token create --replace` kept on with the new token in memory while
`download` read a revoked one from the keyring.

- tokens.rs INVARIANT 4: keeping a token removes every item for its
  account in every collection, makes one and reads it back; reading finds
  every item and, finding several, refuses with how to keep one again; a
  locked or unreachable keyring is KeyringError::Unavailable, never
  "no token". The logic is shared by the OS keyring and the test files
  (now a folder per collection a test can lock, make unreachable, or
  plant a second item in).
- make_token reads the token back before saying anything, or revokes it.
- The bridge tells the agent the keyring is locked or unreachable and the
  token is fine (or that it holds several items), never to make a token;
  it reads the keyring again before every new session, even with a token
  held.
- `wanderbooks token check --book ID [--name N]`: whether the token kept
  here is the books' live one, asked by its SHA-256 (admin op
  token_check), never its text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-22 04:17:22 -06:00

3048 lines
115 KiB
Rust

//! The owner's commands: `wanderbooks serve`, `close`, `books` (list,
//! create, adopt, restore, back-up, upgrade), `token`, `connect`, `doctor`
//! and `passphrase` (plan 3.3, 3.7, 3.9, 5.5), and the same work for the
//! viewer's Books menu (`new_books`, `back_up_now`, `plan_restore`,
//! `carry_out_restore`, `connect_agent`, `close_books`, `hold_for_viewer`,
//! `upgrade_format`).
//!
//! ```text
//! GOAL: from a terminal, or the viewer's menu, the owner holds the books
//! open, closes them, makes, adopts, backs up and restores books and
//! connects agents, 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 and restoring are previewed first and go 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.
//! 4. A token made is kept in the keyring, and read back from it as
//! kept, before anything is said of it, and its text is printed only
//! by `token print`; one that can't be kept (or read back) is revoked
//! again. A token revoked is forgotten by the keyring too. `token
//! check` says whether the keyring's token is the books' live one,
//! by its SHA-256 alone, never its text. `connect` makes the books' agent token only when the keyring
//! has none, replacing the server's (no one could be using it: the
//! bridge reads its token from this keyring).
//! 5. The viewer's hold lets go only when the owner closes the books;
//! books that stop, or won't open, are tried again.
//! 6. Books waiting for a format upgrade (plan 3.9, D-39): the owner's
//! commands that open the books (`serve`, `books`, `token`,
//! `connect`) ask for the go-ahead once each time they run, at a
//! terminal that shows the question (input and output a terminal, in
//! the foreground: the question, then the owner passphrase, which only
//! the server checks); elsewhere they only say so, on stderr. Only a passphrase typed
//! at a terminal (or in the viewer) is sent, through the lease
//! socket's consent_format_upgrade; declining changes nothing.
//! ```
use crate::knobs::{FLAG, Knobs};
use crate::lease::{Ask, HostMsg, Intent, Kind, Reason};
use crate::opener::{HoldError, Lease, Opener};
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);
/// How long the owner's go-ahead for a format upgrade may take: the whole
/// books are backed up and proven first.
const UPGRADE_WAIT: Duration = Duration::from_secs(60 * 60);
/// How long a command waits to learn whether books wait for a format
/// upgrade before it gets on with its own work: a server slow to list
/// them (frozen, or busy) delays the command no more than this.
const OFFER_WAIT: Duration = Duration::from_secs(5);
/// Common flags: `--root DIR` and the test knobs.
#[derive(Default, Clone)]
pub struct Common {
/// The data root named (`--root`), else the one this account's host
/// finds.
pub root: Option<PathBuf>,
/// The test knobs.
pub knobs: Knobs,
/// Ask about books waiting for a format upgrade when the books open
/// (INVARIANT 6): the owner's own commands; never the viewer's work
/// (its window asks) nor the agent's file commands.
pub ask_upgrades: bool,
}
/// Whether this process asked about waiting format upgrades already: once
/// each time a command runs (INVARIANT 6).
static ASKED_UPGRADES: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
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> {
with_server_in(common, request).map(|(answer, _)| answer)
}
/// [`with_server`], with the root's `run/` folder too (where the
/// keyring's test stand-in lives).
fn with_server_in(common: &Common, request: Request) -> Result<(Response, PathBuf), 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, 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 run_dir = opener.run_dir().to_path_buf();
// The books are open: asked about a format upgrade first
// (INVARIANT 6).
if common.ask_upgrades && !ASKED_UPGRADES.swap(true, std::sync::atomic::Ordering::SeqCst) {
let run = run_dir.clone();
let _ = tokio::task::spawn_blocking(move || offer_upgrades(&run)).await;
}
let socket = 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 | Request::TokenList).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.map(|a| (a, run_dir))
});
// 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,
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;
// Asked about a format upgrade as they open (INVARIANT 6).
// A prompt is answered before Ctrl-C lets go: never left
// with the terminal's echo off.
if !ASKED_UPGRADES.swap(true, std::sync::atomic::Ordering::SeqCst) {
let run = opener.run_dir().to_path_buf();
let _ = tokio::task::spawn_blocking(move || offer_upgrades(&run)).await;
}
} 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!(" {prog} restore BACKUP [--id ID] [--replace] [--yes]");
eprintln!(" {prog} back-up ID [--new-disk]");
eprintln!(" {prog} upgrade ID");
eprintln!(
" {prog} put-aside ID DATA-DIR [--vault DIR --vault-key FILE] [PATH...] [--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");
eprintln!(
" back-up back the books up now to every backup their settings name (--new-disk: the backup folder's disk is new)"
);
eprintln!(
" restore bring back a backup (a git repository's address, or BOOK.git in the backup folder), proven whole first: as new books, or replacing books (kept in legacy/)"
);
eprintln!(
" upgrade give the go-ahead, with the owner passphrase, for books an older wanderbooks wrote to move to this version's journal format (a verified backup with the vault and keys first)"
);
eprintln!(
" put-aside move the old copies of adopted books (their data folder, their vault and its key, and other paths given: settings file, git history) whole into the legacy folder, once the books hold everything they do, byte for byte; nothing is deleted"
);
}
/// `wanderbooks books list|create|adopt|restore`.
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 replace = false;
let mut new_disk = 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,
"--replace" => replace = true,
"--new-disk" => new_disk = 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();
// The owner's command: asked about a format upgrade as the books open
// (INVARIANT 6), unless it is the upgrade itself.
common.ask_upgrades = !matches!(words.as_slice(), ["upgrade", ..]);
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,
);
}
["restore", source] => return restore(&common, source, id, replace, yes),
["upgrade", book] => return upgrade(&common, book),
["put-aside", book, source, also @ ..] => {
let also: Vec<PathBuf> = also.iter().map(PathBuf::from).collect();
let old = ledger_store::root::OldCopies {
source: Path::new(source),
vault_dir: vault.as_deref(),
vault_key: vault_key.as_deref(),
also: &also,
expect_head: None,
};
return put_aside(&common, book, old, yes);
}
["back-up", book] => {
return match back_up_now(&common, book, new_disk) {
Ok(lines) => {
for line in lines {
println!("{line}");
}
Ok(())
}
Err(why) => {
eprintln!("error: {why}");
Err(exit(1))
}
};
}
_ => {
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 {
println!("{}", book_line(&b));
}
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))
}
}
}
/// One book, as `books list` and `doctor` show it.
fn book_line(b: &admin::BookStatus) -> String {
match (&b.problem, &b.format_upgrade) {
(Some(why), _) => format!("{} didn't open: {why}", b.id),
(None, Some(_)) => format!(
"{} open, waiting for a format upgrade (`wanderbooks books upgrade {}`)",
b.id, b.id
),
(None, None) => format!("{} open", b.id),
}
}
/// The projection database a book uses, and the other schemas' files
/// beside it (left as they are for the versions that wrote them, never
/// deleted), as `doctor` says them; None when it didn't open.
fn database_line(b: &admin::BookStatus) -> Option<String> {
let database = b.database.as_ref()?;
Some(if b.other_databases.is_empty() {
format!("database: {database}")
} else {
format!(
"database: {database} (also there, left for the versions that use them: {})",
b.other_databases.join(", ")
)
})
}
/// Back the books `book` up now (the server does it): what came of it, a
/// line each; Err when it couldn't be done, or a target didn't take it.
pub fn back_up_now(common: &Common, book: &str, new_disk: bool) -> Result<Vec<String>, String> {
match with_server(
common,
Request::BackUpNow {
id: book.to_string(),
new_disk,
},
)? {
Response::BackedUp { head_seq, targets } => {
let lines = backed_up_lines(head_seq, &targets);
if targets.is_empty() || targets.iter().any(|t| t.problem.is_some()) {
Err(lines.join("\n"))
} else {
Ok(lines)
}
}
Response::Refused(why) => Err(why),
other => Err(format!("the books server answered oddly: {other:?}")),
}
}
/// What a format upgrade the books `book` wait for means, a line each
/// (plan 3.9, D-39): said before the owner is asked.
pub fn upgrade_explained(book: &str, waiting: &admin::FormatWaiting) -> Vec<String> {
vec![
format!(
"The books \"{book}\" wait for a format upgrade: an older version of wanderbooks wrote them, in journal format {}. The upgrade adds {}.",
waiting.format,
waiting.adds.join(", ")
),
"Until you agree they work on their old format: everything that needs nothing new works, and what needs the new format is refused.".into(),
"Upgrading first makes a backup of the books, their vault and keys included, and proves it whole; then it moves them to the new format, in one step. After that an older version of wanderbooks can't open them.".into(),
]
}
/// What a format upgrade did, a line each.
pub fn upgraded_lines(u: &admin::FormatUpgraded) -> Vec<String> {
if u.added.is_empty() {
return vec![format!(
"The books \"{}\" are already in this version's journal format: nothing was changed.",
u.id
)];
}
let mut lines = Vec::new();
if let Some(b) = &u.backup {
lines.push(format!(
"Backed up first, and proven whole with the vault and keys: the books as they were, through event {} (backup {}).",
b.head.0,
short_hash(&b.commit)
));
}
lines.push(format!(
"Upgraded the books \"{}\" to this version's journal format ({}): now at event {} ({}).",
u.id,
u.added.join(", "),
u.head.0,
short_hash(&u.head.1)
));
lines
}
/// Hand the owner's go-ahead for the books `book`'s format upgrade, with
/// the passphrase they typed, to the books' host (the lease socket's
/// consent_format_upgrade; INVARIANT 6), whose server checks it and does
/// it: what came of it, a line each. The books must be open.
fn consent_now(run: &Path, book: &str, passphrase: &str) -> Result<Vec<String>, String> {
let asked = crate::opener::ask_once_within(
run,
Ask::Intent(Intent::ConsentFormatUpgrade {
book: book.to_string(),
passphrase: Some(admin::Passphrase(passphrase.to_string())),
}),
UPGRADE_WAIT,
)
.map_err(|why| {
format!(
"{why}, so it may or may not have been done: `wanderbooks books list` says whether the books still wait for it"
)
})?;
match asked {
Some(HostMsg::Done { detail }) => Ok(detail
.unwrap_or_default()
.lines()
.map(str::to_string)
.collect()),
// Refused: nothing was done (the server says so too).
Some(HostMsg::Refused(why)) => Err(format!("not upgraded: {why}")),
Some(HostMsg::Uncertain(why)) => Err(why),
// Sent, and the host gone without an answer (closed, or killed):
// its server may have done it all the same.
None => Err(
"the books' host gave no answer, so it may or may not have been done: `wanderbooks books list` says whether the books still wait for it"
.into(),
),
Some(other) => Err(format!("the books' host answered oddly: {other:?}")),
}
}
/// The owner's go-ahead for the books `book`'s format upgrade, with the
/// owner passphrase they typed (the viewer's dialog, `books upgrade`): the
/// books held open meanwhile. What came of it, a line each; or why not,
/// which says whether it may have been done all the same.
pub fn upgrade_format(
common: &Common,
book: &str,
passphrase: &str,
) -> Result<Vec<String>, String> {
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.map_err(|e| e.to_string())?;
let (book, passphrase) = (
book.to_string(),
zeroize::Zeroizing::new(passphrase.to_string()),
);
let outcome = rt.block_on(async {
// Nothing is sent until the books are open.
let not_sent = |why: String| format!("not upgraded: {why}");
let mut opener =
Opener::new(common.root.as_deref(), common.knobs, Kind::Cli, None).map_err(not_sent)?;
let lease = open(&mut opener).await.map_err(|e| match e {
HoldError::ClosedByOwner => {
"not upgraded: the books were closed just now (Close the books): nothing was done"
.to_string()
}
HoldError::Failed(why) => not_sent(why),
})?;
let run = opener.run_dir().to_path_buf();
let asked = tokio::task::spawn_blocking(move || consent_now(&run, &book, &passphrase))
.await
.map_err(|e| e.to_string())?;
drop(lease);
asked
});
rt.shutdown_timeout(Duration::from_secs(1));
outcome
}
/// Ask the owner about every book waiting for a format upgrade (INVARIANT
/// 6), the books open: at a terminal, the go-ahead (then the owner
/// passphrase); elsewhere only a note.
fn offer_upgrades(run: &Path) {
let socket = run.join(ledger_mcp::local::ADMIN_SOCKET);
let Ok(Response::Books(books)) = admin::call_within(&socket, Request::List, Some(OFFER_WAIT))
else {
return;
};
for b in books {
if let Some(waiting) = &b.format_upgrade {
offer_upgrade(run, &b.id, waiting);
}
}
}
/// [`offer_upgrades`] for the books `book`. The question comes unasked for,
/// into a command run for something else, so it is asked only where it is
/// seen and answered: at a terminal, the command's output going to it, and
/// run in the foreground (a command whose output goes to a file or a pipe,
/// or a job in the background, would wait unseen for an answer, and mix
/// the question into its output).
fn offer_upgrade(run: &Path, book: &str, waiting: &admin::FormatWaiting) {
// SAFETY: isatty, tcgetpgrp and getpgrp have no preconditions.
let seen = unsafe { libc::isatty(1) == 1 && libc::tcgetpgrp(0) == libc::getpgrp() };
if !(seen && can_type_a_passphrase()) {
eprintln!(
"note: the books \"{book}\" wait for a format upgrade (to {}): until the owner agrees, with `wanderbooks books upgrade {book}` at a terminal or in the app, they work on their old format.",
waiting.adds.join(", ")
);
return;
}
for line in upgrade_explained(book, waiting) {
println!("{line}");
}
let not_now = || {
println!(
"Not now: the books keep working as they are, and you'll be asked again the next time they open (or run `wanderbooks books upgrade {book}`)."
);
};
if !matches!(confirmed("Upgrade them now?", false), Ok(true)) {
not_now();
return;
}
let passphrase = match ask_owner_passphrase() {
Ok(Some(p)) => p,
Ok(None) => {
not_now();
return;
}
Err(why) => {
eprintln!("error: {why}");
return;
}
};
match consent_now(run, book, &passphrase) {
Ok(lines) => {
for line in lines {
println!("{line}");
}
}
Err(why) => eprintln!("error: {why}"),
}
}
/// The owner passphrase, typed at the terminal for a format upgrade; None
/// when nothing was typed (no go-ahead). Never echoed or repeated.
fn ask_owner_passphrase() -> Result<Option<zeroize::Zeroizing<String>>, String> {
let typed = rpassword::prompt_password(
"The owner passphrase, to give the go-ahead (Enter alone: not now): ",
)
.map(zeroize::Zeroizing::new)
.map_err(|e| format!("nothing was read ({})", e.kind()))?;
Ok((!typed.is_empty()).then_some(typed))
}
/// `wanderbooks books upgrade ID` (INVARIANT 6): the owner's go-ahead for
/// the books' format upgrade, at a terminal.
fn upgrade(common: &Common, book: &str) -> Result<(), Box<dyn std::error::Error>> {
let fail = |why: String| -> Result<(), Box<dyn std::error::Error>> {
eprintln!("error: {why}");
Err(exit(1))
};
let book = match ledger_store::root::BookId::parse(book) {
Ok(id) => id.to_string(),
Err(e) => return fail(e.to_string()),
};
let listed = match with_server(common, Request::List) {
Ok(Response::Books(books)) => books,
Ok(Response::Refused(why)) | Err(why) => return fail(why),
Ok(other) => return fail(format!("the books server answered oddly: {other:?}")),
};
let Some(status) = listed.into_iter().find(|b| b.id == book) else {
return fail(format!(
"there are no books called \"{book}\": `wanderbooks books list` lists them"
));
};
if let Some(why) = status.problem {
return fail(format!("the books \"{book}\" didn't open: {why}"));
}
let Some(waiting) = status.format_upgrade else {
println!(
"The books \"{book}\" are already in this version's journal format: nothing to do."
);
return Ok(());
};
for line in upgrade_explained(&book, &waiting) {
println!("{line}");
}
if !can_type_a_passphrase() {
return fail(format!(
"the go-ahead is the owner's, with the owner passphrase typed at a terminal: run `wanderbooks books upgrade {book}` in one (or agree in the app)"
));
}
let passphrase = match ask_owner_passphrase() {
Ok(Some(p)) => p,
Ok(None) => {
println!(
"Not upgraded: the books keep working as they are, and you'll be asked again."
);
return Ok(());
}
Err(why) => return fail(why),
};
match upgrade_format(common, &book, &passphrase) {
Ok(lines) => {
for line in lines {
println!("{line}");
}
Ok(())
}
Err(why) => fail(why),
}
}
/// "New books…": new, empty books `id` in `currency` (the server makes
/// them).
pub fn new_books(common: &Common, id: &str, currency: &str) -> Result<String, String> {
match with_server(
common,
Request::Create {
id: id.trim().to_string(),
currency: currency.trim().to_string(),
},
)? {
Response::Created { id, problem: None } => {
Ok(format!("Made new books \"{id}\" ({}).", currency.trim()))
}
Response::Created {
id,
problem: Some(why),
} => Err(format!(
"new books \"{id}\" were made but didn't open: {why}. Closing the books and opening them again tries again."
)),
Response::Refused(why) => Err(why),
other => Err(format!("the books server answered oddly: {other:?}")),
}
}
/// "Close the books": everything holding them lets go (the lease
/// socket's close intent). What came of it.
pub fn close_books(common: &Common) -> Result<String, String> {
let found =
ledger_store::root::Root::find(common.root.as_deref()).map_err(|e| e.to_string())?;
let run = found.run_dir().map_err(|e| e.to_string())?;
match crate::opener::ask_once_within(&run, Ask::Intent(Intent::Close), HOST_WAIT)? {
None => Ok("The books are closed.".into()),
Some(HostMsg::Done { detail }) => Ok(detail
.map(|d| {
let mut c = d.chars();
c.next()
.map(|first| format!("{}{}.", first.to_uppercase(), c.as_str()))
.unwrap_or_default()
})
.filter(|d| !d.is_empty())
.unwrap_or_else(|| "The books are closing.".into())),
Some(HostMsg::Refused(why)) => Err(why),
Some(HostMsg::Uncertain(why)) => Err(why),
Some(other) => Err(format!("the books' host answered oddly: {other:?}")),
}
}
/// The longest an agent's own command may take to add the books.
const CONNECT_WAIT: Duration = Duration::from_secs(60);
/// How long the viewer's hold waits before trying again after books that
/// wouldn't open.
const VIEWER_RETRY: Duration = Duration::from_secs(30);
/// Hold the books `book` open while the viewer runs (a lease of kind
/// app), on a thread of its own: the window never waits for it. It lets
/// go only when the owner closes the books (and when the viewer ends,
/// with the process); books that stop, or won't open, are tried again.
pub fn hold_for_viewer(common: &Common, book: Option<String>) -> std::thread::JoinHandle<()> {
let common = common.clone();
std::thread::spawn(move || {
let Ok(rt) = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
else {
return;
};
rt.block_on(async {
let Ok(mut opener) = Opener::new(common.root.as_deref(), common.knobs, Kind::App, book)
else {
return;
};
loop {
// The same opener each time round (it knows it held the
// books, so it never reopens books the owner closed); its
// failures counted afresh.
opener.forget_failures();
loop {
let mut lease = match open(&mut opener).await {
Ok(lease) => lease,
Err(HoldError::ClosedByOwner) => return,
Err(HoldError::Failed(_)) => break,
};
match lease.next().await {
Some(HostMsg::Ending {
reason: Reason::ClosedByOwner,
..
}) => return,
Some(HostMsg::Ending {
reason: Reason::Restarting | Reason::Closing,
..
}) => {}
_ => {
if opener.failed().await.is_err() {
break;
}
}
}
}
tokio::time::sleep(VIEWER_RETRY).await;
}
});
})
}
/// What a backup made now came to, a line each.
pub fn backed_up_lines(head_seq: u64, targets: &[admin::BackedUpTarget]) -> Vec<String> {
if targets.is_empty() {
return vec![
"No backups are set up: ask your agent to set the private repository and the backup folder.".into(),
];
}
targets
.iter()
.map(|t| match (&t.problem, t.through_seq) {
(None, Some(seq)) if seq == head_seq => {
format!("Backed up to {} (through event {seq}).", t.at)
}
(None, Some(seq)) => format!("Backed up to {} through event {seq}.", t.at),
(None, None) => format!("Not yet backed up to {}.", t.at),
(Some(why), _) => format!("Not backed up to {}: {why}", t.at),
})
.collect()
}
/// `wanderbooks connect claude|codex --book ID`: an agent of the owner's
/// reaches the books.
pub fn connect(
prog: &str,
args: impl IntoIterator<Item = String>,
) -> Result<(), Box<dyn std::error::Error>> {
let mut common = Common::default();
let mut book: Option<String> = None;
let mut words: Vec<String> = Vec::new();
let mut args = args.into_iter();
while let Some(arg) = args.next() {
if common.take(&arg, &mut args)? {
continue;
}
match arg.as_str() {
"--book" => book = Some(args.next().ok_or("--book needs an id")?),
"--help" | "-h" => {
eprintln!("usage: {prog} claude|codex --book ID");
eprintln!(
" Lets Claude Code or Codex reach the books: keeps their token in this computer's keyring and adds the books to the agent's own list."
);
return Ok(());
}
s if s.starts_with('-') => {
eprintln!("unknown flag: {s}");
eprintln!("usage: {prog} claude|codex --book ID");
return Err(exit(2));
}
_ => words.push(arg),
}
}
let (Some(agent), Some(book), 1) = (words.first(), book, words.len()) else {
eprintln!("usage: {prog} claude|codex --book ID");
return Err(exit(2));
};
// The owner's command (INVARIANT 6).
common.ask_upgrades = true;
match connect_agent(&common, agent, &book) {
Ok(lines) => {
for line in lines {
println!("{line}");
}
Ok(())
}
Err(why) => {
eprintln!("error: {why}");
Err(exit(1))
}
}
}
/// Let the agent `agent` ("claude" or "codex") reach the books `book`:
/// their token kept in the keyring (made if none is), and `wanderbooks
/// mcp --book <book>` added to the agent's own list as
/// `wanderbooks-<book>`. What was done, a line each.
pub fn connect_agent(common: &Common, agent: &str, book: &str) -> Result<Vec<String>, String> {
let (cli, label) = match agent {
"claude" => ("claude", "Claude Code"),
"codex" => ("codex", "Codex"),
other => return Err(format!("{other:?}: connect claude or codex")),
};
let book = ledger_store::root::BookId::parse(book)
.map_err(|e| e.to_string())?
.to_string();
// The PATH first; then where the agents' installers put them (a
// viewer started from the desktop has a short PATH).
// ($HOME: a test's own home is never the account's.)
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.filter(|h| h.is_absolute());
let usual = [
".local/bin",
".npm-global/bin",
".bun/bin",
".cargo/bin",
".volta/bin",
]
.into_iter()
.filter_map(|d| home.as_ref().map(|h| h.join(d)))
.chain(
["/usr/local/bin", "/opt/homebrew/bin"]
.into_iter()
.map(PathBuf::from),
);
let program = std::env::var_os("PATH")
.map(|p| std::env::split_paths(&p).collect::<Vec<_>>())
.unwrap_or_default()
.into_iter()
.chain(usual)
.filter(|d| d.is_absolute())
.map(|d| d.join(cli))
.find(|p| {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
})
.ok_or_else(|| {
format!("{label} isn't installed here (no `{cli}` on the PATH, nor where its installer puts it)")
})?;
let mut lines = Vec::new();
// The token: kept in the keyring, or made now.
let found =
ledger_store::root::Root::find(common.root.as_deref()).map_err(|e| e.to_string())?;
let run = found.run_dir().map_err(|e| e.to_string())?;
let account = crate::tokens::Account::new(found.path(), &book, crate::tokens::DEFAULT_NAME);
if crate::tokens::keeper(&run, &common.knobs)
.get(&account)?
.is_none()
{
let scopes = ledger_store::auth::Scope::ALL
.iter()
.map(|s| s.as_str().to_string())
.collect();
make_token(common, &book, crate::tokens::DEFAULT_NAME, scopes, true)?;
lines.push(format!(
"Made the agent token of the books {book}, kept in this computer's keyring."
));
}
// The agent's own list: the books' entry made anew.
let exe = crate::start::this_executable()?;
let name = format!("wanderbooks-{book}");
let mut serve: Vec<std::ffi::OsString> = vec![
exe.into_os_string(),
"mcp".into(),
"--book".into(),
book.clone().into(),
];
if common.root.is_some() {
serve.push("--root".into());
serve.push(found.path().as_os_str().to_os_string());
}
serve.extend(common.knobs.args().into_iter().map(Into::into));
let scope: &[&str] = if agent == "claude" {
&["--scope", "user"]
} else {
&[]
};
// Each call within a minute: an agent's command that hangs is said,
// never waited on for good.
let run_cli = |args: Vec<std::ffi::OsString>| -> Result<std::process::Output, String> {
let mut child = std::process::Command::new(&program)
.args(args)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| format!("{label} couldn't be run: {}", e.kind()))?;
let deadline = std::time::Instant::now() + CONNECT_WAIT;
loop {
if child.try_wait().map_err(|e| e.to_string())?.is_some() {
return child.wait_with_output().map_err(|e| e.to_string());
}
if std::time::Instant::now() > deadline {
let _ = child.kill();
let _ = child.wait();
return Err(format!("{label} didn't answer in time: nothing was added"));
}
std::thread::sleep(Duration::from_millis(50));
}
};
let mut remove: Vec<std::ffi::OsString> = vec!["mcp".into(), "remove".into()];
remove.extend(scope.iter().map(Into::into));
remove.push(name.clone().into());
let _ = run_cli(remove);
let mut add: Vec<std::ffi::OsString> = vec!["mcp".into(), "add".into()];
add.extend(scope.iter().map(Into::into));
add.push(name.clone().into());
add.push("--".into());
add.extend(serve);
let out = run_cli(add)?;
if !out.status.success() {
let said: String = String::from_utf8_lossy(&out.stderr)
.trim()
.chars()
.take(300)
.collect();
return Err(format!("{label} didn't add the books: {said}"));
}
lines.push(format!(
"{label} reaches the books {book} as \"{name}\" (restart {label} to see it)."
));
Ok(lines)
}
/// Make the token `name` for `book` (the server does) and keep it in this
/// computer's keyring (INVARIANT 4: kept first, or taken back).
fn make_token(
common: &Common,
book: &str,
name: &str,
scopes: Vec<String>,
replace: bool,
) -> Result<admin::TokenInfo, String> {
let request = Request::TokenCreate {
book: book.to_string(),
name: name.to_string(),
scopes,
replace,
};
let (answer, run_dir) = with_server_in(common, request)?;
let (info, text) = match answer {
Response::TokenCreated { token, text } => (token, zeroize::Zeroizing::new(text.0)),
Response::Refused(why) => return Err(why),
other => return Err(format!("the books server answered oddly: {other:?}")),
};
let root = run_dir.parent().unwrap_or(&run_dir).to_path_buf();
let account = crate::tokens::Account::new(&root, book, name);
// Kept, and read back as kept: else taken back (INVARIANT 4).
let keeper = crate::tokens::keeper(&run_dir, &common.knobs);
let kept = keeper
.set(&account, &text)
.map_err(String::from)
.and_then(|()| match keeper.get(&account) {
Ok(Some(back)) if back == *text => Ok(()),
Ok(_) => Err("it didn't give back the token it was given to keep".to_string()),
Err(e) => Err(e.to_string()),
});
drop(text);
if let Err(why) = kept {
let undone = with_server(
common,
Request::TokenRevoke {
book: book.to_string(),
name: name.to_string(),
// This one only: another may have replaced it.
id: Some(info.id.clone()),
},
);
let after = match undone {
Ok(Response::TokenRevoked(_)) => "so it was revoked again",
_ => "and revoking it again failed too: `wanderbooks token revoke` ends it",
};
let replaced = if replace {
" The token it replaced had already ended."
} else {
""
};
return Err(format!(
"the token was made, but this computer's keyring couldn't keep it ({why}), {after}.{replaced}"
));
}
Ok(info)
}
/// The id a backup is restored as when none is given: its repository's
/// name (`acme.git` → `acme`).
fn restore_id(source: &str) -> Option<String> {
let last = source
.trim_end_matches('/')
.rsplit(['/', ':'])
.next()?
.trim_end_matches(".git");
ledger_store::root::BookId::parse(last)
.ok()
.map(|id| id.to_string())
}
/// Ask at the terminal (or take --yes): whether to go ahead.
fn confirmed(question: &str, yes: bool) -> Result<bool, String> {
if yes {
return Ok(true);
}
if !at_a_terminal() {
return Err("confirm at a terminal, or run again with --yes".into());
}
print!("{question} [y/N] ");
use std::io::Write;
let _ = std::io::stdout().flush();
let mut answer = String::new();
std::io::stdin()
.read_line(&mut answer)
.map_err(|e| e.to_string())?;
Ok(matches!(answer.trim(), "y" | "Y" | "yes" | "Yes"))
}
/// `wanderbooks books restore` (INVARIANT 3).
fn restore(
common: &Common,
source: &str,
id: Option<String>,
replace: 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 plan = match plan_restore(common, source, id, replace, true) {
Ok(plan) => plan,
Err(why) => return fail(why),
};
for line in &plan.lines {
println!("{line}");
}
match confirmed("Restore it?", yes) {
Ok(true) => {}
Ok(false) => {
println!("Not restored.");
return Ok(());
}
Err(why) => return fail(format!("not restored: {why}")),
}
match carry_out_restore(common, &plan) {
Ok(lines) => {
for line in lines {
println!("{line}");
}
Ok(())
}
Err(why) => fail(why),
}
}
/// A restore previewed: the backup proven, the id it comes back as, and
/// what it would do, a line each (INVARIANT 3: shown before it is done).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RestorePlan {
/// The backup, as the server is told it (a folder by its full path).
pub source: String,
/// The books' id it comes back as.
pub id: String,
/// Whether it replaces the books of that id (kept in legacy/).
pub replace: bool,
/// The backup's head, proven: the restore is of this backup only.
pub head: (u64, String),
/// The id of the books it holds, if it says.
pub origin: Option<String>,
/// What it would do, a line each.
pub lines: Vec<String>,
}
/// Preview restoring the backup at `source` (`flags`: name the command
/// line's flags in the advice): the id it comes back as is `id`, else the
/// id of the books the backup holds, else the repository's name; refused
/// over books that exist unless `replace`, and replacing none.
pub fn plan_restore(
common: &Common,
source: &str,
id: Option<String>,
replace: bool,
flags: bool,
) -> Result<RestorePlan, String> {
// A folder is named to the server by its full path (the server's
// working folder isn't this one); anything else is an address.
let source = match Path::new(source) {
p if p.exists() => std::path::absolute(p)
.unwrap_or_else(|_| p.to_path_buf())
.display()
.to_string(),
_ => source.trim().to_string(),
};
// One preview (under the id given, or any: it only reports), then the
// id.
let probe = id
.clone()
.or_else(|| restore_id(&source))
.unwrap_or_else(|| "restored".to_string());
let preview = match with_server(
common,
Request::Restore {
id: probe,
source: source.clone(),
replace,
confirmed: false,
expect_head: None,
},
)? {
Response::RestorePreview(p) => p,
Response::Refused(why) => return Err(format!("{source}: {why}")),
other => return Err(format!("the books server answered oddly: {other:?}")),
};
let flag = |text: &str| {
if flags {
format!(" ({text})")
} else {
String::new()
}
};
let Some(id) = id
.or_else(|| {
preview
.origin
.clone()
.filter(|o| ledger_store::root::BookId::parse(o).is_ok())
})
.or_else(|| restore_id(&source))
else {
return Err(format!("name the restored books{}", flag("--id ID")));
};
let exists = match with_server(common, Request::List)? {
Response::Books(books) => books.iter().any(|b| b.id == id),
Response::Refused(why) => return Err(why),
other => return Err(format!("the books server answered oddly: {other:?}")),
};
if exists && !replace {
return Err(format!(
"there are already books called \"{id}\": restore them under another id{}, or replace them{}",
flag("--id"),
flag("--replace; they are kept in legacy/")
));
}
if !exists && replace {
return Err(format!(
"there are no books called \"{id}\" to replace: nothing was restored"
));
}
let mut lines = vec![format!(
"The backup at {source} is whole: through event {} ({}), {} stored files re-hashed, {} TIN vault records.",
preview.head.0,
short_hash(&preview.head.1),
preview.blobs,
preview.vault_files
)];
if let Some(origin) = &preview.origin {
lines.push(format!("It is a backup of the books \"{origin}\"."));
}
lines.push(if replace {
format!(
"It replaces the books \"{id}\": they are closed and kept whole in the legacy folder, not deleted."
)
} else {
format!("It comes back as new books \"{id}\", beside any others.")
});
match preview.origin.as_deref() {
Some(o) if o == id => {}
Some(o) => lines.push(format!(
"Under a name other than its books' (\"{o}\"), they won't back up to the private repository those books back up to (that would stop theirs): if these are those books, restore them as \"{o}\"{}; else name a repository of their own.",
flag(&format!("--id {o}"))
)),
None => lines.push(
"It doesn't say whose books it holds: they won't back up to the private repository their settings name until one of their own is named."
.into(),
),
}
Ok(RestorePlan {
source,
id,
replace,
head: preview.head,
origin: preview.origin,
lines,
})
}
/// Restore as `plan` previewed it (only that backup, at that head): what
/// was done, a line each.
pub fn carry_out_restore(common: &Common, plan: &RestorePlan) -> Result<Vec<String>, String> {
let request = Request::Restore {
id: plan.id.clone(),
source: plan.source.clone(),
replace: plan.replace,
confirmed: true,
expect_head: Some(plan.head.clone()),
};
match with_server(common, request)? {
Response::Restored(r) => {
let mut lines = vec![format!(
"Restored as \"{}\": through event {} ({}), {} stored files checked, {} TIN vault records.",
r.id,
r.head.0,
short_hash(&r.head.1),
r.blobs_verified,
r.vault_files
)];
if let Some(kept) = &r.replaced {
lines.push(format!(
"The books it replaced are kept in the legacy folder as {kept}."
));
}
if let Some(why) = r.problem {
return Err(format!(
"{}\nThe restored books didn't open: {why}. Closing the books and opening them again tries again.",
lines.join("\n")
));
}
Ok(lines)
}
Response::Refused(why) => Err(format!("{}: {why}", plan.source)),
other => Err(format!("the books server answered oddly: {other:?}")),
}
}
/// 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:?}")),
}
}
/// `wanderbooks books put-aside` (INVARIANT 8 of the root): what goes
/// aside and what was checked, asked for (or --yes), then moved, only if
/// the old copy is still as the preview read it.
fn put_aside(
common: &Common,
book: &str,
old: ledger_store::root::OldCopies<'_>,
yes: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let fail = |why: String| -> Result<(), Box<dyn std::error::Error>> {
eprintln!("error: {why}");
Err(exit(1))
};
if old.vault_dir.is_some() != old.vault_key.is_some() {
return fail(
"give the vault's folder and its key together, with --vault DIR --vault-key FILE"
.into(),
);
}
let root = match ledger_store::root::Root::find(common.root.as_deref()) {
Ok(root) => root,
Err(e) => return fail(e.to_string()),
};
let id = match ledger_store::root::BookId::parse(book) {
Ok(id) => id,
Err(e) => return fail(e.to_string()),
};
let preview = match root.put_aside(&id, &old, false) {
Ok(p) => p,
Err(e) => return fail(e.to_string()),
};
let mut held = format!(
"its journal through event {} ({}) and its {} stored files",
preview.head.0,
short_hash(&preview.head.1),
preview.blob_files
);
if preview.key_checked {
held.push_str(&format!(
", its {} vault records and the vault's key",
preview.vault_files
));
}
let alike = if preview.vault_changed.is_empty() {
"byte for byte"
} else {
"byte for byte, or newer"
};
println!("The books \"{id}\" hold everything the old copy does, {alike}: {held}.");
println!(
"These go aside, each whole in a numbered folder of its own, into the legacy folder as {}:",
preview.name
);
for (k, item) in preview.items.iter().enumerate() {
println!(" {} {}", k + 1, item.display());
}
if !preview.vault_changed.is_empty() {
println!(
"The books have sealed these vault records anew since (theirs is newer; the old ones go aside too):"
);
for rel in &preview.vault_changed {
println!(" {}", rel.display());
}
}
if !preview.left_behind.is_empty() {
println!(
"These files beside the stored files were never part of the books (left behind by adoption; they go aside too):"
);
for rel in &preview.left_behind {
println!(" {}", rel.display());
}
}
println!("Nothing is deleted; a list of where each came from is kept with them.");
if !confirmed("Put them aside?", yes).map_err(|why| {
eprintln!("error: not put aside: {why}");
exit(1)
})? {
println!("Nothing was put aside.");
return Ok(());
}
let old = ledger_store::root::OldCopies {
expect_head: Some(&preview.head),
..old
};
match root.put_aside(&id, &old, true) {
Ok(done) => {
println!(
"Put aside: {} paths, in the legacy folder as {}.",
done.items.len(),
done.name
);
Ok(())
}
Err(e) => fail(e.to_string()),
}
}
/// 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.format_upgraded {
lines.push(
"The copy is in this version's journal format (the original keeps its own).".into(),
);
}
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.");
match root
.auth_dir()
.map_err(|e| e.to_string())
.and_then(|a| ledger_store::passphrase::is_set(&a).map_err(|e| e.to_string()))
{
Ok(true) => println!("The owner passphrase: set."),
Ok(false) => println!(
"The owner passphrase: not set yet: set it in a terminal with `wanderbooks passphrase`."
),
Err(why) => println!("The owner passphrase: {why}"),
}
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 {
println!(" {}", book_line(&b));
if let Some(line) = database_line(&b) {
println!(" {line}");
}
}
// Agents' tokens: how many each book has.
match admin::call_within(&socket, Request::TokenList, Some(DOCTOR_WAIT)) {
Ok(admin::Response::Tokens(tokens)) if tokens.is_empty() => println!(
"Agents' tokens: none yet (`wanderbooks token create --book ID`)."
),
Ok(admin::Response::Tokens(tokens)) => {
println!("Agents' tokens:");
for t in tokens {
println!(" {} {} {}", t.book, t.name, t.scopes.join(","));
}
}
Ok(admin::Response::Refused(why)) => println!("Agents' tokens: {why}"),
Ok(_) | Err(_) => {}
}
}
} 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(())
}
fn token_usage(prog: &str) {
eprintln!("usage: {prog} create --book ID [--name NAME] [--scopes LIST] [--replace]");
eprintln!(" {prog} list");
eprintln!(" {prog} revoke --book ID [--name NAME]");
eprintln!(" {prog} check --book ID [--name NAME]");
eprintln!(" {prog} print --book ID [--name NAME]");
eprintln!(" create make an agent's token for the books ID and keep it in this");
eprintln!(" computer's keyring, where `wanderbooks mcp --book ID` finds it");
eprintln!(" (NAME: agent unless given; LIST: read,write,post,admin unless");
eprintln!(" given; --replace ends a token of the same name)");
eprintln!(" list the tokens there are (never their text)");
eprintln!(" revoke end a token, at once, and forget it here");
eprintln!(" check whether the token kept here is the books' live one (never");
eprintln!(" its text: exit 0 if it is)");
eprintln!(" print the token's text, for a client that sends it itself");
}
/// `wanderbooks token create|list|revoke|check|print`.
pub fn token(
prog: &str,
args: impl IntoIterator<Item = String>,
) -> Result<(), Box<dyn std::error::Error>> {
use ledger_store::auth::Scope;
let mut common = Common::default();
let mut words: Vec<String> = Vec::new();
let mut book: Option<String> = None;
let mut name = crate::tokens::DEFAULT_NAME.to_string();
let mut scopes: Vec<String> = Scope::ALL.iter().map(|s| s.as_str().to_string()).collect();
let mut replace = 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() {
"--book" => book = Some(value("an id")?),
"--name" => name = value("a name")?,
"--scopes" => {
let list = value("a list such as read,write")?;
let mut out = Vec::new();
for part in list.split(',').map(str::trim).filter(|p| !p.is_empty()) {
match Scope::parse(part) {
Some(s) => out.push(s.as_str().to_string()),
None => {
eprintln!(
"error: {part:?} isn't a scope: use read, write, post or admin"
);
return Err(exit(2));
}
}
}
scopes = out;
}
"--replace" => replace = true,
"--help" | "-h" => {
token_usage(prog);
return Ok(());
}
s if s.starts_with('-') => {
eprintln!("unknown flag: {s}");
token_usage(prog);
return Err(exit(2));
}
_ => words.push(arg),
}
}
// The owner's command (INVARIANT 6).
common.ask_upgrades = true;
let fail = |why: String| -> Box<dyn std::error::Error> {
eprintln!("error: {why}");
exit(1)
};
let need_book = || -> Result<String, Box<dyn std::error::Error>> {
let Some(book) = book.clone() else {
eprintln!("error: which books? give --book ID");
return Err(exit(2));
};
ledger_store::root::BookId::parse(&book)
.map(|id| id.to_string())
.map_err(|e| fail(e.to_string()))
};
let words: Vec<&str> = words.iter().map(String::as_str).collect();
match words.as_slice() {
["create"] => {
let book = need_book()?;
let info = make_token(&common, &book, &name, scopes, replace).map_err(fail)?;
println!(
"Made the token {:?} for the books {} ({}), kept in this computer's keyring.",
info.name,
info.book,
info.scopes.join(", ")
);
println!(
"An agent reaches the books with `wanderbooks mcp --book {}{}`.",
info.book,
if info.name == crate::tokens::DEFAULT_NAME {
String::new()
} else {
format!(" --token {}", info.name)
}
);
Ok(())
}
["list"] => match with_server(&common, Request::TokenList).map_err(fail)? {
Response::Tokens(tokens) if tokens.is_empty() => {
println!("No tokens yet: `wanderbooks token create --book ID` makes one.");
Ok(())
}
Response::Tokens(tokens) => {
for t in tokens {
println!(
"{} {} {} made {}",
t.book,
t.name,
t.scopes.join(","),
t.created
);
}
Ok(())
}
Response::Refused(why) => Err(fail(why)),
other => Err(fail(format!("the books server answered oddly: {other:?}"))),
},
["revoke"] => {
let book = need_book()?;
let request = Request::TokenRevoke {
book: book.clone(),
name: name.clone(),
id: None,
};
let (answer, run_dir) = with_server_in(&common, request).map_err(fail)?;
match answer {
Response::TokenRevoked(t) => {
let root = run_dir.parent().unwrap_or(&run_dir).to_path_buf();
let account = crate::tokens::Account::new(&root, &book, &name);
let forgot = crate::tokens::keeper(&run_dir, &common.knobs).delete(&account);
println!("Revoked the token {:?} for the books {}.", t.name, t.book);
if let Err(why) = forgot {
eprintln!(
"warning: this computer's keyring still holds its text ({why}); it opens nothing now"
);
}
Ok(())
}
Response::Refused(why) => Err(fail(why)),
other => Err(fail(format!("the books server answered oddly: {other:?}"))),
}
}
["check"] => {
let book = need_book()?;
let opener = Opener::new(common.root.as_deref(), common.knobs, Kind::Cli, None)
.map_err(|e| fail(e.to_string()))?;
let run_dir = opener.run_dir().to_path_buf();
let root = run_dir.parent().unwrap_or(&run_dir).to_path_buf();
let account = crate::tokens::Account::new(&root, &book, &name);
// Its SHA-256 alone leaves here (INVARIANT 4).
let kept = crate::tokens::keeper(&run_dir, &common.knobs)
.get(&account)
.map_err(|e| {
fail(format!(
"this computer's keyring couldn't give the token {name:?} of the books {book}: {e}"
))
})?
.map(|text| {
let text = zeroize::Zeroizing::new(text);
ledger_store::journal::sha256_hex(text.as_bytes())
});
let request = Request::TokenCheck {
book: book.clone(),
name: name.clone(),
sha256: kept.clone().unwrap_or_default(),
};
let (live, matches) = match with_server(&common, request).map_err(fail)? {
Response::TokenChecked { live, matches } => (live, matches),
Response::Refused(why) => {
return Err(fail(format!(
"the books server didn't take the check (one older than this command, perhaps): {why}"
)));
}
other => return Err(fail(format!("the books server answered oddly: {other:?}"))),
};
let named = if name == crate::tokens::DEFAULT_NAME {
String::new()
} else {
format!(" --name {name}")
};
let remake = format!("`wanderbooks token create --book {book}{named} --replace`");
let said = match (kept.is_some(), live, matches) {
(true, Some(t), true) => {
println!(
"The token {name:?} of the books {book} kept in this computer's keyring is the live one (made {}).",
t.created
);
return Ok(());
}
(true, Some(t), false) => format!(
"This computer's keyring holds a token {name:?} for the books {book} that isn't the live one (made {}): {remake} makes one and keeps it here; then reconnect the agent.",
t.created
),
(true, None, _) => format!(
"This computer's keyring holds a token {name:?} for the books {book} that they no longer have (it was revoked): `wanderbooks token create --book {book}{named}` makes one."
),
(false, Some(t), _) => format!(
"This computer's keyring keeps no token {name:?} for the books {book}, though they have a live one (made {}): {remake} makes one kept here.",
t.created
),
(false, None, _) => format!(
"There is no token {name:?} for the books {book}: `wanderbooks token create --book {book}{named}` makes one."
),
};
println!("{said}");
Err(exit(1))
}
["print"] => {
let book = need_book()?;
let opener = Opener::new(common.root.as_deref(), common.knobs, Kind::Cli, None)
.map_err(|e| fail(e.to_string()))?;
let run_dir = opener.run_dir().to_path_buf();
let root = run_dir.parent().unwrap_or(&run_dir).to_path_buf();
let account = crate::tokens::Account::new(&root, &book, &name);
match crate::tokens::keeper(&run_dir, &common.knobs).get(&account) {
Ok(Some(text)) => {
println!("{text}");
Ok(())
}
Ok(None) => Err(fail(format!(
"this computer keeps no token {name:?} for the books {book}: `wanderbooks token create --book {book}` makes one"
))),
Err(why) => Err(fail(why.into())),
}
}
_ => {
token_usage(prog);
Err(exit(2))
}
}
}
/// Whether a person can type a passphrase here: standard input is a
/// terminal, and the terminal itself can be opened (it is read from).
fn can_type_a_passphrase() -> bool {
at_a_terminal() && std::fs::File::open("/dev/tty").is_ok()
}
/// Ask for the owner passphrase at the terminal and set it in `auth_dir`
/// (ledger_store::passphrase): the one in force first, if there is one,
/// then the new one twice. Nothing typed is echoed or repeated.
pub fn set_passphrase_at_terminal(auth_dir: &Path) -> Result<(), String> {
use ledger_store::passphrase;
if !can_type_a_passphrase() {
return Err(
"the owner passphrase is typed by a person, at a terminal: run `wanderbooks passphrase` in one"
.into(),
);
}
// What is typed is wiped from memory when done with.
let ask = |prompt: &str| {
rpassword::prompt_password(prompt)
.map(zeroize::Zeroizing::new)
.map_err(|e| format!("nothing was read ({})", e.kind()))
};
let current = if passphrase::is_set(auth_dir).map_err(|e| e.to_string())? {
let c = ask("The owner passphrase in force: ")?;
if !passphrase::verify(auth_dir, &c).map_err(|e| e.to_string())? {
return Err(passphrase::PassError::Wrong.to_string());
}
Some(c)
} else {
None
};
let new = ask(&format!(
"A new owner passphrase ({} characters or more): ",
passphrase::MIN_CHARS
))?;
passphrase::check_length(&new).map_err(|e| e.to_string())?;
if *ask("The same again: ")? != *new {
return Err("the two didn't match: nothing was changed".into());
}
passphrase::set(auth_dir, &new, current.as_ref().map(|c| c.as_str())).map_err(|e| e.to_string())
}
/// `wanderbooks passphrase`: set or change the owner passphrase.
pub fn passphrase(
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!(" Set the owner passphrase, or change it (the one in force is asked");
eprintln!(" first). It is what the books ask a person for before anything only");
eprintln!(" the owner may do. Typed at a terminal, never shown.");
return Ok(());
}
other => {
eprintln!("unknown argument: {other}");
return Err(exit(2));
}
}
}
let fail = |why: String| -> Box<dyn std::error::Error> {
eprintln!("error: {why}");
exit(1)
};
let root =
ledger_store::root::Root::find(common.root.as_deref()).map_err(|e| fail(e.to_string()))?;
let auth = root.auth_dir().map_err(|e| fail(e.to_string()))?;
set_passphrase_at_terminal(&auth).map_err(fail)?;
println!("The owner passphrase is set.");
Ok(())
}
/// A request to a book over the server's socket, with the agent's token
/// from the keyring, the books held open meanwhile: its status, the
/// headers wanted, and its body.
fn on_the_socket(
common: &Common,
book: &str,
token_name: &str,
request: impl FnOnce(&reqwest::Client, String) -> reqwest::RequestBuilder,
) -> Result<(u16, reqwest::header::HeaderMap, Vec<u8>), 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,
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 run_dir = opener.run_dir().to_path_buf();
let root = run_dir.parent().unwrap_or(&run_dir).to_path_buf();
let account = crate::tokens::Account::new(&root, book, token_name);
let keeper = crate::tokens::keeper(&run_dir, &common.knobs);
let token = tokio::task::spawn_blocking(move || keeper.get(&account))
.await
.map_err(|e| e.to_string())??
.ok_or_else(|| {
format!(
"this computer has no token for the books {book} (named {token_name:?}): the human makes one with `wanderbooks token create --book {book}`"
)
})?;
let token = zeroize::Zeroizing::new(token);
let socket = ledger_store::root::SocketPath::new(&run_dir, ledger_mcp::local::MCP_SOCKET)
.map_err(|e| format!("can't reach the books server: {e}"))?;
let http = reqwest::Client::builder()
.unix_socket(socket.path())
.no_proxy()
.build()
.map_err(|e| e.to_string())?;
let response = request(&http, format!("http://localhost/b/{book}"))
.bearer_auth(token.as_str())
.send()
.await
.map_err(|e| format!("the books server didn't answer: {e}"))?;
let status = response.status().as_u16();
let headers = response.headers().clone();
let body = response
.bytes()
.await
.map_err(|e| format!("the books server's answer was cut off: {e}"))?
.to_vec();
drop(lease);
Ok::<_, String>((status, headers, body))
});
rt.shutdown_timeout(Duration::from_secs(1));
outcome
}
/// The flags `upload` and `download` share: the books, the token's name,
/// and the words given.
struct FileArgs {
common: Common,
book: String,
token: String,
words: Vec<std::ffi::OsString>,
to: Option<PathBuf>,
}
fn file_args(
args: impl IntoIterator<Item = std::ffi::OsString>,
usage: &dyn Fn(),
) -> Result<Option<FileArgs>, Box<dyn std::error::Error>> {
let mut common = Common::default();
let mut book = None;
let mut token = crate::tokens::DEFAULT_NAME.to_string();
let mut words = Vec::new();
let mut to = None;
let mut args = args.into_iter();
// Flags and their values are text; a file's path, or the folder
// `--to` names, may be any name the system takes.
let text = |v: std::ffi::OsString, what: &str| {
v.into_string()
.map_err(|_| format!("{what} isn't UTF-8 text"))
};
while let Some(arg) = args.next() {
// After `--`, words only (a file whose name starts with `-`).
if arg == "--" {
words.extend(args.by_ref());
break;
}
let Some(flag) = arg.to_str().filter(|a| a.starts_with('-')) else {
words.push(arg);
continue;
};
let flag = flag.to_string();
let mut value = |what: &str| args.next().ok_or_else(|| format!("{flag} needs {what}"));
match flag.as_str() {
"--root" => common.root = Some(PathBuf::from(value("a folder")?)),
"--book" => book = Some(text(value("an id")?, "--book's id")?),
"--token" => token = text(value("a name")?, "--token's name")?,
"--to" => to = Some(PathBuf::from(value("a folder")?)),
"--help" | "-h" => {
usage();
return Ok(None);
}
f if f == FLAG => {
common.knobs = Knobs::parse(&text(value("a value")?, FLAG)?)?;
}
s => {
eprintln!("unknown flag: {s}");
usage();
return Err(exit(2));
}
}
}
let Some(book) = book else {
eprintln!("error: which books? give --book ID");
return Err(exit(2));
};
let book = ledger_store::root::BookId::parse(&book)
.map(|id| id.to_string())
.map_err(|e| e.to_string())?;
Ok(Some(FileArgs {
common,
book,
token,
words,
to,
}))
}
/// `wanderbooks upload --book ID UPLOAD FILE`: send a file's bytes for an
/// upload begun with `books.begin_upload` (plan 4.7, D-28: the agent runs
/// it).
pub fn upload(
prog: &str,
args: impl IntoIterator<Item = std::ffi::OsString>,
) -> Result<(), Box<dyn std::error::Error>> {
let usage = || {
eprintln!("usage: {prog} --book ID [--token NAME] UPLOAD [--] FILE");
eprintln!(" Send FILE for UPLOAD (from books.begin_upload), with the agent's token.");
};
let Some(a) = file_args(args, &usage)? else {
return Ok(());
};
let ([upload, file], None) = (a.words.as_slice(), &a.to) else {
usage();
return Err(exit(2));
};
let (Some(upload), file) = (
upload.to_str().filter(|u| ledger_store::uploads::is_id(u)),
std::path::Path::new(file).display(),
) else {
eprintln!(
"error: that isn't an upload's id (books.begin_upload gives one: up_ and 32 letters)"
);
return Err(exit(2));
};
let path = std::path::Path::new(&a.words[1]);
let fail = |why: String| -> Box<dyn std::error::Error> {
eprintln!("error: {why}");
exit(1)
};
let max = ledger_store::uploads::MAX_UPLOAD;
match std::fs::metadata(path) {
Ok(m) if !m.is_file() => return Err(fail(format!("{file} isn't a file"))),
Ok(m) if m.len() > max => {
return Err(fail(format!(
"{file} is {} bytes; an upload is at most {max}",
m.len()
)));
}
Ok(_) => {}
Err(e) => return Err(fail(format!("can't read {file}: {}", e.kind()))),
}
let bytes =
std::fs::read(path).map_err(|e| fail(format!("can't read {file}: {}", e.kind())))?;
let size = bytes.len();
let (status, _, body) = on_the_socket(&a.common, &a.book, &a.token, |http, base| {
http.put(format!("{base}/u/{upload}")).body(bytes)
})
.map_err(fail)?;
let said = String::from_utf8_lossy(&body).to_string();
if status != 200 {
return Err(fail(said));
}
println!("Sent {size} bytes for {upload}: name it (upload_id) in the tool that files it.");
Ok(())
}
/// The `Content-Disposition` parameters (RFC 6266): name and value, a
/// quoted value unquoted; a `;` or `=` inside quotes is part of the value.
fn disposition_params(value: &str) -> Vec<(String, String)> {
let mut params = Vec::new();
let mut rest = value.split_once(';').map_or("", |(_, r)| r);
loop {
rest = rest.trim_start_matches([';', ' ', '\t']);
let Some((name, after)) = rest.split_once('=') else {
return params;
};
let name = name.trim().to_ascii_lowercase();
let after = after.trim_start();
let (v, next) = if let Some(quoted) = after.strip_prefix('"') {
let mut v = String::new();
let mut chars = quoted.char_indices();
let mut end = quoted.len();
while let Some((i, c)) = chars.next() {
match c {
'\\' => {
if let Some((_, e)) = chars.next() {
v.push(e);
}
}
'"' => {
end = i + 1;
break;
}
c => v.push(c),
}
}
(v, &quoted[end.min(quoted.len())..])
} else {
let end = after.find(';').unwrap_or(after.len());
(after[..end].trim().to_string(), &after[end..])
};
params.push((name, v));
rest = next;
}
}
/// The longest name a download is saved under, in bytes (under every
/// file system's limit, with room for " (999)").
const SAVED_NAME_MAX: usize = 200;
/// The name a download is saved under: the server's (RFC 6266), its UTF-8
/// `filename*` first; a plain file name, never hidden, nothing invisible
/// in it, at most SAVED_NAME_MAX bytes (its extension kept).
fn saved_name(headers: &reqwest::header::HeaderMap) -> Option<String> {
let value = headers
.get(reqwest::header::CONTENT_DISPOSITION)?
.to_str()
.ok()?;
let params = disposition_params(value);
let utf8 = params
.iter()
.find(|(n, _)| n == "filename*")
.and_then(|(_, v)| v.strip_prefix("UTF-8''"))
.and_then(percent_decoded);
let plain = || {
params
.iter()
.find(|(n, _)| n == "filename")
.map(|(_, v)| v.clone())
};
plain_name(&utf8.or_else(plain)?)
}
/// `name` made a plain, visible file name of at most SAVED_NAME_MAX
/// bytes: folders' separators and control characters become `_`,
/// invisible ones go, leading dots and spaces go; None if nothing's left.
fn plain_name(name: &str) -> Option<String> {
let cleaned: String = name
.chars()
.filter(|c| !ledger_store::uploads::is_hidden_char(*c))
.map(|c| {
if c == '/' || c == '\\' || c.is_control() {
'_'
} else {
c
}
})
.collect();
let cleaned = cleaned.trim().trim_start_matches(['.', ' ']).to_string();
if cleaned.is_empty() {
return None;
}
if cleaned.len() <= SAVED_NAME_MAX {
return Some(cleaned);
}
// Too long: the stem cut on a character boundary, the extension kept
// (if it is short).
let (stem, ext) = match cleaned.rsplit_once('.') {
Some((s, e)) if !s.is_empty() && e.len() <= 16 => (s, format!(".{e}")),
_ => (cleaned.as_str(), String::new()),
};
let mut cut = SAVED_NAME_MAX - ext.len();
while !stem.is_char_boundary(cut) {
cut -= 1;
}
Some(format!("{}{ext}", stem[..cut].trim_end()))
}
/// `%XX`-escaped UTF-8 (RFC 8187), or None when it isn't.
fn percent_decoded(text: &str) -> Option<String> {
let mut bytes = Vec::with_capacity(text.len());
let mut it = text.bytes();
while let Some(b) = it.next() {
if b == b'%' {
let hex = [it.next()?, it.next()?];
bytes.push(u8::from_str_radix(std::str::from_utf8(&hex).ok()?, 16).ok()?);
} else {
bytes.push(b);
}
}
String::from_utf8(bytes).ok()
}
/// Whether `body` is what the server stored: its SHA-256 is the one the
/// server sent (RFC 9530 `Repr-Digest: sha-256=:BASE64:`). No digest, or
/// one that can't be read, is no match.
fn arrived_whole(headers: &reqwest::header::HeaderMap, body: &[u8]) -> bool {
let sent = headers
.get(ledger_mcp::local::DIGEST_HEADER)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.trim().strip_prefix("sha-256=:"))
.and_then(|v| v.strip_suffix(':'))
.and_then(|v| base64::Engine::decode(&base64::engine::general_purpose::STANDARD, v).ok());
sent.as_deref() == Some(&<sha2::Sha256 as sha2::Digest>::digest(body)[..])
}
/// `wanderbooks download --book ID ATTACHMENT --to FOLDER`: save a copy of
/// a stored file (one too large for books.get_receipt to return).
pub fn download(
prog: &str,
args: impl IntoIterator<Item = std::ffi::OsString>,
) -> Result<(), Box<dyn std::error::Error>> {
let usage = || {
eprintln!("usage: {prog} --book ID [--token NAME] ATTACHMENT|EXPORT --to FOLDER");
eprintln!(" Save a copy of the stored file ATTACHMENT (a number), or of a file the");
eprintln!(" books made (an EXPORT: ex_ and 32 letters), in FOLDER (a new file).");
};
let Some(a) = file_args(args, &usage)? else {
return Ok(());
};
let ([attachment], Some(to)) = (a.words.as_slice(), a.to.clone()) else {
usage();
return Err(exit(2));
};
// An attachment's id (a number as the books write it), or an
// export's: each has its own route.
let route = match attachment.to_str() {
Some(a) if a.parse::<u64>().is_ok_and(|n| n.to_string() == a) => "a",
Some(x) if ledger_store::exports::is_id(x) => "x",
_ => {
eprintln!(
"error: that is neither an attachment's id (a number) nor an export's (ex_ and 32 letters)"
);
return Err(exit(2));
}
};
let attachment = attachment.to_str().expect("checked above");
let fail = |why: String| -> Box<dyn std::error::Error> {
eprintln!("error: {why}");
exit(1)
};
if !to.is_dir() {
return Err(fail(format!("{} isn't a folder", to.display())));
}
let (status, headers, body) = on_the_socket(&a.common, &a.book, &a.token, |http, base| {
http.get(format!("{base}/{route}/{attachment}"))
})
.map_err(fail)?;
let path = keep(status, &headers, &body, attachment, &to).map_err(fail)?;
println!("Saved {} ({} bytes).", path.display(), body.len());
Ok(())
}
/// What `download` does with the server's answer: the server's words if it
/// refused; nothing if the bytes aren't the stored ones; else a new file
/// in `to` (its path).
fn keep(
status: u16,
headers: &reqwest::header::HeaderMap,
body: &[u8],
attachment: &str,
to: &std::path::Path,
) -> Result<PathBuf, String> {
if status != 200 {
return Err(String::from_utf8_lossy(body).to_string());
}
if !arrived_whole(headers, body) {
return Err(
"the file arrived damaged (its SHA-256 isn't the stored one): nothing was saved".into(),
);
}
let fallback = match attachment.strip_prefix("ex_") {
Some(_) => format!("export-{attachment}"),
None => format!("attachment-{attachment}"),
};
let named = saved_name(headers).unwrap_or_else(|| fallback.clone());
ledger_store::saving::save_new(to, &named, &fallback, body)
}
#[cfg(test)]
mod tests {
use super::*;
fn disposition(value: &str) -> reqwest::header::HeaderMap {
let mut h = reqwest::header::HeaderMap::new();
h.insert(
reqwest::header::CONTENT_DISPOSITION,
reqwest::header::HeaderValue::from_str(value).unwrap(),
);
h
}
#[test]
fn a_download_is_saved_under_a_plain_visible_name() {
let named = |v: &str| saved_name(&disposition(v));
// The UTF-8 name first, else the plain one.
assert_eq!(
named("attachment; filename=\"R_sum_.pdf\"; filename*=UTF-8''R%C3%A9sum%C3%A9.pdf")
.as_deref(),
Some("Résumé.pdf")
);
assert_eq!(
named("attachment; filename=\"a b.pdf\"").as_deref(),
Some("a b.pdf")
);
// A parameter inside the quoted name is the name, not a parameter.
assert_eq!(
named("attachment; filename=\"x; filename*=UTF-8''.bashrc; y\"").as_deref(),
Some("x; filename*=UTF-8''.bashrc; y")
);
// No folder, nothing hidden, nothing invisible.
for (given, saved) in [
(
"filename*=UTF-8''..%2F..%2F.ssh%2Fauthorized_keys",
Some("_.._.ssh_authorized_keys"),
),
("filename*=UTF-8''%2Fetc%2Fpasswd", Some("_etc_passwd")),
("filename*=UTF-8''.bashrc", Some("bashrc")),
("filename*=UTF-8''rcpt%E2%80%AEfdp.exe", Some("rcptfdp.exe")),
("filename*=UTF-8''a%E2%80%8Bb.pdf", Some("ab.pdf")),
("filename*=UTF-8''a%0Ab.pdf", Some("a_b.pdf")),
("filename*=UTF-8''..", None),
("filename*=UTF-8''%20.", None),
("filename*=UTF-8''%ZZ", None),
("filename=\"\"", None),
] {
assert_eq!(
named(&format!("attachment; {given}")).as_deref(),
saved,
"{given}"
);
}
assert_eq!(saved_name(&reqwest::header::HeaderMap::new()), None);
// Too long: cut on a character, its extension kept.
let long = "收".repeat(90) + ".pdf";
let cut = plain_name(&long).unwrap();
assert!(
cut.len() <= SAVED_NAME_MAX && cut.ends_with("收.pdf"),
"{cut}"
);
assert_eq!(
plain_name(&"a".repeat(SAVED_NAME_MAX)).unwrap().len(),
SAVED_NAME_MAX
);
assert_eq!(percent_decoded("%E2%9C%93").as_deref(), Some("✓"));
assert_eq!(percent_decoded("%E2%9C"), None, "not UTF-8");
assert_eq!(percent_decoded("%4"), None);
}
#[test]
fn only_bytes_with_the_digest_sent_arrived_whole() {
let body = b"%PDF-1.4 stored";
let digest = base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
<sha2::Sha256 as sha2::Digest>::digest(body),
);
let with = |v: &str| {
let mut h = reqwest::header::HeaderMap::new();
h.insert(
ledger_mcp::local::DIGEST_HEADER,
reqwest::header::HeaderValue::from_str(v).unwrap(),
);
h
};
assert!(arrived_whole(&with(&format!("sha-256=:{digest}:")), body));
assert!(!arrived_whole(
&with(&format!("sha-256=:{digest}:")),
b"%PDF-1.4 changed"
));
assert!(!arrived_whole(&with(&format!("sha-512=:{digest}:")), body));
assert!(!arrived_whole(&with(&format!("sha-256={digest}")), body));
assert!(!arrived_whole(&with("sha-256=:not base64:"), body));
assert!(!arrived_whole(&reqwest::header::HeaderMap::new(), body));
}
#[test]
fn only_a_whole_file_the_server_gave_is_kept() {
let dir = tempfile::tempdir().unwrap();
let body = b"%PDF-1.4 stored";
let mut h = disposition("attachment; filename=\"r.pdf\"");
let digest = |b: &[u8]| {
format!(
"sha-256=:{}:",
base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
<sha2::Sha256 as sha2::Digest>::digest(b)
)
)
};
h.insert(
ledger_mcp::local::DIGEST_HEADER,
reqwest::header::HeaderValue::from_str(&digest(b"something else")).unwrap(),
);
let e = keep(200, &h, body, "7", dir.path()).unwrap_err();
assert!(e.contains("arrived damaged"), "{e}");
let e = keep(404, &h, b"there is no such attachment", "7", dir.path()).unwrap_err();
assert_eq!(e, "there is no such attachment");
assert_eq!(
std::fs::read_dir(dir.path()).unwrap().count(),
0,
"nothing saved"
);
h.insert(
ledger_mcp::local::DIGEST_HEADER,
reqwest::header::HeaderValue::from_str(&digest(body)).unwrap(),
);
assert_eq!(
keep(200, &h, body, "7", dir.path()).unwrap(),
dir.path().join("r.pdf")
);
let bare = reqwest::header::HeaderMap::new();
assert!(
keep(200, &bare, body, "7", dir.path()).is_err(),
"no digest, no file"
);
}
#[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,
format_upgraded: false,
problem: None,
}
}
#[test]
fn a_backup_is_restored_under_its_repository_name() {
for (source, want) in [
("/media/disk/backups/acme.git", Some("acme")),
("/media/disk/backups/acme.git/", Some("acme")),
("git@host:owner/acme-books.git", Some("acme-books")),
("ssh://host/owner/acme", Some("acme")),
("https://host/owner/acme.git", Some("acme")),
("acme.git", Some("acme")),
("/media/disk/Backups/Acme.git", None),
("/", None),
("", None),
] {
assert_eq!(restore_id(source).as_deref(), want, "{source}");
}
}
#[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,
format_upgraded: true,
..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.",
"The copy is in this version's journal format (the original keeps its own).",
"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()));
}
/// What the owner is told of a format upgrade: what they wait for,
/// what was done (the backup first, then the format), and that books
/// already in this version's format had nothing done.
#[test]
fn a_format_upgrade_is_told_in_plain_words() {
let waiting = admin::FormatWaiting {
format: 1,
features: Vec::new(),
adds: vec!["journal format 2".into()],
};
let told = upgrade_explained("acme", &waiting).join(" ");
for says in [
"\"acme\" wait for a format upgrade",
"in journal format 1",
"The upgrade adds journal format 2.",
"Until you agree they work on their old format",
"vault and keys included",
"an older version of wanderbooks can't open them",
] {
assert!(told.contains(says), "{says}: {told}");
}
let status =
|problem: Option<&str>, waiting: Option<admin::FormatWaiting>| admin::BookStatus {
id: "acme".into(),
open: problem.is_none(),
problem: problem.map(str::to_string),
format_upgrade: waiting,
database: None,
other_databases: Vec::new(),
};
assert_eq!(book_line(&status(None, None)), "acme open");
assert_eq!(
book_line(&status(None, Some(waiting.clone()))),
"acme open, waiting for a format upgrade (`wanderbooks books upgrade acme`)"
);
assert_eq!(
book_line(&status(Some("damaged"), Some(waiting))),
"acme didn't open: damaged"
);
let done = admin::FormatUpgraded {
id: "acme".into(),
added: vec!["journal format 2".into()],
backup: Some(admin::UpgradeBackup {
commit: "0123456789abcdef".repeat(2),
head: (7, "a".repeat(64)),
}),
head: (8, "b".repeat(64)),
};
assert_eq!(
upgraded_lines(&done),
[
"Backed up first, and proven whole with the vault and keys: the books as they were, through event 7 (backup 0123456789ab).",
"Upgraded the books \"acme\" to this version's journal format (journal format 2): now at event 8 (bbbbbbbbbbbb).",
]
);
assert_eq!(
upgraded_lines(&admin::FormatUpgraded {
added: Vec::new(),
backup: None,
..done
}),
[
"The books \"acme\" are already in this version's journal format: nothing was changed."
]
);
}
/// `doctor` names the database file a book uses, and any other
/// schema's beside it as left alone; nothing for books that didn't
/// open.
#[test]
fn doctor_names_the_database_in_use_and_the_others_left_alone() {
let mut b = admin::BookStatus {
id: "acme".into(),
open: true,
problem: None,
format_upgrade: None,
database: Some("wanderbooks.v13.db".into()),
other_databases: Vec::new(),
};
assert_eq!(
database_line(&b).as_deref(),
Some("database: wanderbooks.v13.db")
);
b.other_databases = vec!["wanderbooks.db".into(), "wanderbooks.v14.db".into()];
assert_eq!(
database_line(&b).as_deref(),
Some(
"database: wanderbooks.v13.db (also there, left for the versions that use them: wanderbooks.db, wanderbooks.v14.db)"
)
);
b.database = None;
assert_eq!(database_line(&b), None);
}
#[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());
}
}