Files
wanderbooks/crates/ledger-store/tests/root.rs
T
swaitsandClaude Opus 5.5 019d8d76a3 P2 slice 7: backups and restore (D-30, D-31)
The server backs each book up itself: a per-book git archive, pushed
fast-forward only to a git remote and to a folder, when the books open,
after a quiet spell, before they close and on request; every commit,
push and drill is a record in ops/backup.jsonl. Drills clone each
target and verify the chain, the blobs, the vault and the head, weekly
and after a failure. `wanderbooks books restore` previews, then
restores beside or (with --replace) over, never moving books something
holds. The archive refuses anything but a plain bare repository and
pins git against the owner's configuration. books.backup and
books.back_up_now for the agent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-20 11:14:21 -06:00

1351 lines
50 KiB
Rust

//! The data root (root.rs): one private folder for every book, found
//! without the environment, and books adopted into it whole.
#![cfg(unix)]
use chrono::NaiveDate;
use ledger_core::settings::{Change, Source};
use ledger_core::{AccountType, Currency, Money, Side};
use ledger_store::root::{
AdoptOptions, BookId, LOCATION_FILE, MARKER_FILE, Root, RootError, default_root,
platform_data_dir,
};
use ledger_store::{JOURNAL_FILE, JournalLine, Snapshot, Store, StoreError};
use rust_decimal::Decimal;
use serde_json::json;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
fn tmp(tag: &str) -> tempfile::TempDir {
tempfile::Builder::new()
.prefix(&format!("wb-root-{tag}-"))
.tempdir()
.unwrap()
}
fn mode(path: &Path) -> u32 {
std::fs::metadata(path).unwrap().permissions().mode() & 0o777
}
fn src(by: &str) -> Source {
Source {
decided_by: by.into(),
decided_on: NaiveDate::from_ymd_opt(2026, 10, 18).unwrap(),
citation: None,
note: None,
}
}
fn set(key: &str, value: serde_json::Value) -> Change {
Change {
key: key.into(),
value: Some(value),
}
}
/// Books with accounts, a posted entry with its receipt, a filed document,
/// and (with `settings`) settings of their own: what adoption must carry.
fn source_books(dir: &Path, settings: bool) -> Store {
let mut s = Store::create(dir, Currency::USD).unwrap();
if settings {
s.adopt_settings(&[set("company.name", json!("Adopted LLC"))], &src("Owner"))
.unwrap();
}
let bank = s
.add_account("1000", "Bank", AccountType::Asset, None, false)
.unwrap();
let fees = s
.add_account("6000", "Fees", AccountType::Expense, None, false)
.unwrap();
let e = s
.create_entry(NaiveDate::from_ymd_opt(2026, 10, 9).unwrap(), "Filing fee")
.unwrap();
let m = Money::new(Decimal::new(5900, 2), Currency::USD);
s.add_line(e, fees, Side::Debit, m).unwrap();
s.add_line(e, bank, Side::Credit, m).unwrap();
s.attach_receipt(
Some(e),
"fee.pdf",
"application/pdf",
b"%PDF fee receipt",
None,
)
.unwrap();
s.post_entry(e).unwrap();
s.file_document(
"formation",
"cert.pdf",
"application/pdf",
b"%PDF certificate",
None,
)
.unwrap();
s
}
/// Every file under `dir`, with its bytes, for before/after comparison.
fn snapshot_files(dir: &Path) -> Vec<(PathBuf, Vec<u8>)> {
let mut out = Vec::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(d) = stack.pop() {
for e in std::fs::read_dir(&d).unwrap() {
let e = e.unwrap();
let p = e.path();
if e.file_type().unwrap().is_dir() {
stack.push(p);
} else {
out.push((p.clone(), std::fs::read(&p).unwrap()));
}
}
}
out.sort();
out
}
/// The error of a call that must fail (a Store isn't Debug).
fn refused<T>(r: Result<T, RootError>) -> RootError {
match r {
Ok(_) => panic!("expected a refusal"),
Err(e) => e,
}
}
fn id(s: &str) -> BookId {
BookId::parse(s).unwrap()
}
#[test]
fn book_ids_are_plain_lowercase_names() {
for ok in ["a", "fernhill", "acme-2026", "0", &"x".repeat(40)] {
assert!(BookId::parse(ok).is_ok(), "{ok:?}");
}
for bad in [
"",
"-a",
"A",
"acme_llc",
"acme llc",
"acme.",
"..",
"a/b",
"é",
&"x".repeat(41),
] {
assert!(
matches!(BookId::parse(bad), Err(RootError::BadId(_))),
"{bad:?}"
);
}
}
#[test]
fn the_root_is_found_without_the_environment() {
let home = tmp("find");
// Nothing recorded: the platform default under the home.
assert_eq!(
Root::locate(home.path(), None).unwrap(),
default_root(home.path())
);
assert_eq!(
default_root(home.path()),
home.path().join(".local/share/wanderbooks")
);
// An explicit root wins.
let elsewhere = home.path().join("elsewhere");
assert_eq!(
Root::locate(home.path(), Some(&elsewhere)).unwrap(),
elsewhere
);
// A recorded root is used when none is given.
std::fs::create_dir_all(platform_data_dir(home.path())).unwrap();
std::fs::write(
platform_data_dir(home.path()).join(LOCATION_FILE),
format!("{}\n", elsewhere.display()),
)
.unwrap();
assert_eq!(Root::locate(home.path(), None).unwrap(), elsewhere);
// A relative record is refused, not guessed at.
std::fs::write(
platform_data_dir(home.path()).join(LOCATION_FILE),
"relative/root\n",
)
.unwrap();
assert!(matches!(
Root::locate(home.path(), None),
Err(RootError::Unsafe(_))
));
}
#[test]
fn setup_makes_a_private_root_and_its_folders() {
let home = tmp("setup");
let (root, report) = Root::setup(home.path(), None, None).unwrap();
assert_eq!(root.path(), default_root(home.path()));
assert!(report.created && !report.tightened && !report.recorded);
assert_eq!(mode(root.path()), 0o700);
for sub in ["books", "run", "legacy"] {
assert_eq!(mode(&root.path().join(sub)), 0o700, "{sub}");
}
// Again: nothing to do, nothing recorded.
let (_, again) = Root::setup(home.path(), None, None).unwrap();
assert!(!again.created && !again.tightened && !again.recorded);
assert!(Root::find_in(home.path(), None).is_ok());
}
#[test]
fn setup_records_a_root_chosen_elsewhere_and_forgets_it_when_back_to_default() {
let home = tmp("setup-elsewhere");
let chosen = home.path().join("my books");
let (_, report) = Root::setup(home.path(), Some(&chosen), None).unwrap();
assert!(report.recorded);
let pointer = platform_data_dir(home.path()).join(LOCATION_FILE);
assert_eq!(mode(&pointer), 0o600);
assert_eq!(Root::find_in(home.path(), None).unwrap().path(), chosen);
// Back to the default (it holds no books): the record goes.
let (root, report) = Root::setup(home.path(), Some(&default_root(home.path())), None).unwrap();
assert_eq!(root.path(), default_root(home.path()));
assert!(!report.recorded && !pointer.exists());
// $XDG_DATA_HOME is honoured at setup, when nothing is recorded, and
// recorded.
let fresh = tmp("setup-xdg");
let xdg = fresh.path().join("xdg");
let (root, report) = Root::setup(fresh.path(), None, Some(&xdg)).unwrap();
assert_eq!(root.path(), xdg.join("wanderbooks"));
assert!(report.recorded);
assert_eq!(
Root::find_in(fresh.path(), None).unwrap().path(),
xdg.join("wanderbooks")
);
// A relative XDG_DATA_HOME is ignored, as the spec says.
let other = tmp("setup-rel");
let (root, report) = Root::setup(other.path(), None, Some(Path::new("rel"))).unwrap();
assert_eq!(root.path(), default_root(other.path()));
assert!(!report.recorded);
}
#[test]
fn setup_tightens_a_root_others_can_enter_and_open_refuses_one() {
let home = tmp("tighten");
let root = default_root(home.path());
std::fs::create_dir_all(&root).unwrap();
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(matches!(Root::open(&root), Err(RootError::Unsafe(_))));
let (_, report) = Root::setup(home.path(), None, None).unwrap();
assert!(report.tightened && !report.created);
assert_eq!(mode(&root), 0o700);
// A folder inside that others can enter is refused too.
std::fs::set_permissions(root.join("books"), std::fs::Permissions::from_mode(0o750)).unwrap();
let opened = Root::open(&root).unwrap();
assert!(matches!(opened.list_books(), Err(RootError::Unsafe(_))));
// Setup makes it private again.
let (_, report) = Root::setup(home.path(), None, None).unwrap();
assert!(report.tightened);
assert_eq!(mode(&root.join("books")), 0o700);
assert!(opened.list_books().unwrap().is_empty());
}
#[test]
fn a_root_that_is_a_link_or_a_file_or_missing_is_refused() {
let home = tmp("unsafe");
let real = home.path().join("real");
std::fs::create_dir(&real).unwrap();
std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o700)).unwrap();
let link = home.path().join("link");
std::os::unix::fs::symlink(&real, &link).unwrap();
assert!(matches!(Root::open(&link), Err(RootError::Unsafe(_))));
assert!(matches!(
Root::setup(home.path(), Some(&link), None),
Err(RootError::Unsafe(_))
));
let file = home.path().join("file");
std::fs::write(&file, "x").unwrap();
assert!(matches!(Root::open(&file), Err(RootError::Unsafe(_))));
assert!(matches!(
Root::open(&home.path().join("none")),
Err(RootError::Missing)
));
// A private folder setup didn't make (no marker) isn't a root.
assert!(matches!(Root::open(&real), Err(RootError::Unsafe(why)) if why.contains("no marker")));
}
#[test]
fn books_are_created_listed_and_never_made_twice() {
let home = tmp("create");
let (root, _) = Root::setup(home.path(), None, None).unwrap();
assert!(root.list_books().unwrap().is_empty());
let store = root.create_book(&id("acme"), Currency::USD).unwrap();
assert_eq!(store.data_dir(), root.book_dir(&id("acme")));
drop(store);
assert_eq!(mode(&root.book_dir(&id("acme"))), 0o700);
assert!(matches!(
root.create_book(&id("acme"), Currency::USD),
Err(RootError::Exists(_))
));
root.create_book(&id("beta"), Currency::USD).unwrap();
// Folders that aren't books, or aren't named as books are, are not listed.
std::fs::create_dir(root.path().join("books/.adopt-x-1")).unwrap();
std::fs::create_dir(root.path().join("books/empty")).unwrap();
std::fs::create_dir(root.path().join("books/Bad_Name")).unwrap();
std::fs::write(root.path().join("books/Bad_Name").join(JOURNAL_FILE), "").unwrap();
assert_eq!(root.list_books().unwrap(), vec![id("acme"), id("beta")]);
}
#[test]
fn adopted_books_are_the_same_books_and_the_source_is_untouched() {
let home = tmp("adopt");
let (root, _) = Root::setup(home.path(), None, None).unwrap();
let old = tmp("adopt-src");
let data = old.path().join("books-data");
let source = source_books(&data, true);
let source_head = source.head();
let source_ledger = source.ledger().clone();
drop(source);
// A vault and a key beside them, as the old layout kept them.
let vault = old.path().join("vault");
std::fs::create_dir(&vault).unwrap();
std::fs::write(vault.join("tin-7.bin"), b"sealed record").unwrap();
let key = old.path().join("vault.key");
std::fs::write(&key, b"0123456789abcdef0123456789abcdef").unwrap();
let before = snapshot_files(old.path());
let report = root
.adopt(
&data,
&id("acme"),
&AdoptOptions {
settings: None,
vault_dir: Some(vault.clone()),
vault_key: Some(key.clone()),
// As a preview read it: the same head, so it goes ahead.
expect_head: Some(source_head.clone()),
},
)
.unwrap();
assert_eq!(report.source_head, source_head);
assert_eq!(
report.head, source_head,
"books with settings gain no event"
);
assert_eq!(report.blob_files, 2);
assert_eq!(report.blobs_verified, 2);
assert_eq!(
(
report.vault_files,
report.key_copied,
report.settings_imported
),
(1, true, 0)
);
// The same books: every reader agrees with the source's ledger.
let dir = root.book_dir(&id("acme"));
let adopted = Store::open(&dir).unwrap();
adopted.verify().unwrap();
assert!(adopted.ledger().differs_from(&source_ledger).is_empty());
drop(adopted);
assert!(
Snapshot::load(&dir)
.unwrap()
.ledger()
.differs_from(&source_ledger)
.is_empty()
);
// Private, inside and out.
assert_eq!(mode(&dir), 0o700);
assert_eq!(mode(&dir.join("journal.jsonl")), 0o600);
assert_eq!(mode(&dir.join("vault")), 0o700);
assert_eq!(mode(&dir.join("vault/tin-7.bin")), 0o600);
assert_eq!(mode(&dir.join("keys/legacy.key")), 0o600);
assert_eq!(
std::fs::read(dir.join("keys/legacy.key")).unwrap(),
std::fs::read(&key).unwrap()
);
// The source: byte for byte as it was, nothing added or gone.
assert_eq!(snapshot_files(old.path()), before);
// No staging left behind; the book is listed.
assert_eq!(root.list_books().unwrap(), vec![id("acme")]);
assert_eq!(
std::fs::read_dir(root.path().join("books"))
.unwrap()
.count(),
1
);
// Adopted once only.
assert!(matches!(
root.adopt(&data, &id("acme"), &AdoptOptions::default()),
Err(RootError::Exists(_))
));
}
#[test]
fn books_without_settings_need_them_and_get_them_on_the_way_in() {
let home = tmp("adopt-settings");
let (root, _) = Root::setup(home.path(), None, None).unwrap();
let old = tmp("adopt-settings-src");
let source = source_books(old.path(), false);
let head = source.head();
drop(source);
match root.adopt(old.path(), &id("acme"), &AdoptOptions::default()) {
Err(RootError::Adopt(why)) => assert!(why.contains("keep no settings"), "{why}"),
other => panic!("{other:?}"),
}
assert!(root.list_books().unwrap().is_empty());
let report = root
.adopt(
old.path(),
&id("acme"),
&AdoptOptions {
settings: Some((
vec![set("company.name", json!("From The File LLC"))],
src("Owner"),
)),
..AdoptOptions::default()
},
)
.unwrap();
assert_eq!(report.source_head, head);
assert_eq!(report.settings_imported, 1);
// What was added: the journal format the settings need, declared
// first (older books), then the settings.
let journal = std::fs::read_to_string(root.book_dir(&id("acme")).join(JOURNAL_FILE)).unwrap();
let added: Vec<String> = journal
.lines()
.skip(usize::try_from(head.0).unwrap())
.map(|l| {
serde_json::from_str::<serde_json::Value>(l).unwrap()["type"]
.as_str()
.unwrap()
.to_string()
})
.collect();
let format_declaration = Store::create(&tmp("probe").path().join("d"), Currency::USD)
.map(|mut s| {
s.adopt_settings(&[], &src("x")).unwrap();
let j = std::fs::read_to_string(s.data_dir().join(JOURNAL_FILE)).unwrap();
j.lines()
.map(|l| {
serde_json::from_str::<serde_json::Value>(l).unwrap()["type"]
.as_str()
.unwrap()
.to_string()
})
.collect::<Vec<_>>()
})
.unwrap();
// The same steps a fresh store takes to keep settings, after store.created.
assert_eq!(added, format_declaration[1..].to_vec());
assert_eq!(added.last().map(String::as_str), Some("settings.set"));
assert_eq!(report.head.0, head.0 + added.len() as u64);
let adopted = Store::open(&root.book_dir(&id("acme"))).unwrap();
assert!(adopted.ledger().settings().is_journaled());
assert_eq!(
adopted.ledger().settings().company("USD").name,
"From The File LLC"
);
}
#[test]
fn books_another_writer_has_open_are_refused() {
let home = tmp("adopt-locked");
let (root, _) = Root::setup(home.path(), None, None).unwrap();
let old = tmp("adopt-locked-src");
let open = source_books(old.path(), true);
let err = root
.adopt(old.path(), &id("acme"), &AdoptOptions::default())
.unwrap_err();
assert!(
matches!(err.store_error(), Some(StoreError::Locked(_))),
"{err:?}"
);
drop(open);
assert!(root.list_books().unwrap().is_empty());
assert_eq!(
std::fs::read_dir(root.path().join("books"))
.unwrap()
.count(),
0
);
}
#[test]
fn a_cut_off_or_damaged_source_is_refused_and_nothing_is_left_behind() {
let home = tmp("adopt-bad");
let (root, _) = Root::setup(home.path(), None, None).unwrap();
// A cut-off last line: refused, the source not repaired.
let torn = tmp("adopt-torn");
drop(source_books(torn.path(), true));
let journal = torn.path().join(JOURNAL_FILE);
let mut bytes = std::fs::read(&journal).unwrap();
bytes.extend_from_slice(b"{\"seq\":99");
std::fs::write(&journal, &bytes).unwrap();
match root.adopt(torn.path(), &id("torn"), &AdoptOptions::default()) {
Err(RootError::Adopt(why)) => assert!(why.contains("cut off"), "{why}"),
other => panic!("{other:?}"),
}
assert_eq!(std::fs::read(&journal).unwrap(), bytes);
// A blob whose bytes changed: the copy fails its re-hash, and no book
// (nor its staging folder) appears.
let damaged = tmp("adopt-damaged");
drop(source_books(damaged.path(), true));
let blob = walk_files(&damaged.path().join("blobs")).remove(0);
std::fs::write(&blob, b"not what was stored").unwrap();
match root.adopt(damaged.path(), &id("damaged"), &AdoptOptions::default()) {
Err(RootError::Adopt(why)) => {
assert!(why.contains("doesn't match its fingerprint"), "{why}")
}
other => panic!("{other:?}"),
}
assert!(root.list_books().unwrap().is_empty());
assert_eq!(
std::fs::read_dir(root.path().join("books"))
.unwrap()
.count(),
0
);
// A link among the blobs is refused.
let linked = tmp("adopt-linked");
drop(source_books(linked.path(), true));
std::os::unix::fs::symlink("/etc/hostname", linked.path().join("blobs/link")).unwrap();
match root.adopt(linked.path(), &id("linked"), &AdoptOptions::default()) {
Err(RootError::Adopt(why)) => assert!(why.contains("isn't a stored file"), "{why}"),
other => panic!("{other:?}"),
}
assert_eq!(
std::fs::read_dir(root.path().join("books"))
.unwrap()
.count(),
0
);
// Not books at all.
let empty = tmp("adopt-empty");
match root.adopt(empty.path(), &id("empty"), &AdoptOptions::default()) {
Err(RootError::Adopt(why)) => assert!(why.contains("no journal.jsonl"), "{why}"),
other => panic!("{other:?}"),
}
}
#[test]
fn books_inside_the_root_are_not_adopted_into_it() {
let home = tmp("adopt-inside");
let (root, _) = Root::setup(home.path(), None, None).unwrap();
drop(root.create_book(&id("acme"), Currency::USD).unwrap());
match root.adopt(
&root.book_dir(&id("acme")),
&id("copy"),
&AdoptOptions::default(),
) {
Err(RootError::Adopt(why)) => {
assert!(why.contains("inside the books' private folder"), "{why}")
}
other => panic!("{other:?}"),
}
// Nor the folder holding the root.
match root.adopt(home.path(), &id("copy"), &AdoptOptions::default()) {
Err(RootError::Adopt(_)) => {}
other => panic!("{other:?}"),
}
}
fn walk_files(dir: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
for e in std::fs::read_dir(dir).unwrap() {
let p = e.unwrap().path();
if p.is_dir() {
out.extend(walk_files(&p));
} else {
out.push(p);
}
}
out.sort();
out
}
/// Append a line, properly chained, of a type this version doesn't know:
/// what a newer wanderbooks would write.
fn append_newer_line(data: &Path) {
use sha2::{Digest, Sha256};
let journal = data.join(JOURNAL_FILE);
let text = std::fs::read_to_string(&journal).unwrap();
let last: JournalLine = serde_json::from_str(text.lines().last().unwrap()).unwrap();
let next = JournalLine {
seq: last.seq + 1,
ts: last.ts.clone(),
line_type: "future.thing".into(),
payload: json!({}),
prev: hex::encode(Sha256::digest(last.to_canonical().as_bytes())),
};
std::fs::write(&journal, format!("{text}{}\n", next.to_canonical())).unwrap();
}
#[test]
fn setup_run_again_keeps_the_recorded_root_and_never_moves_books() {
let home = tmp("setup-keep");
let chosen = home.path().join("chosen");
let (root, _) = Root::setup(home.path(), Some(&chosen), None).unwrap();
drop(root.create_book(&id("acme"), Currency::USD).unwrap());
// Run again with nothing given (and XDG set): the recorded root stays.
let (again, report) = Root::setup(home.path(), None, Some(&home.path().join("xdg"))).unwrap();
assert_eq!(again.path(), chosen);
assert!(report.recorded);
assert_eq!(Root::find_in(home.path(), None).unwrap().path(), chosen);
// A root elsewhere while this one holds books: refused, nothing changed.
let other = home.path().join("other");
match Root::setup(home.path(), Some(&other), None) {
Err(RootError::Unsafe(why)) => assert!(why.contains("may hold books"), "{why}"),
x => panic!("{x:?}"),
}
assert!(!other.exists());
assert_eq!(Root::find_in(home.path(), None).unwrap().path(), chosen);
match Root::setup(home.path(), Some(&default_root(home.path())), None) {
Err(RootError::Unsafe(why)) => assert!(why.contains("may hold books"), "{why}"),
x => panic!("{x:?}"),
}
}
#[test]
fn setup_leaves_a_folder_that_holds_other_things_alone() {
let home = tmp("setup-full");
let full = home.path().join("Documents");
std::fs::create_dir(&full).unwrap();
std::fs::set_permissions(&full, std::fs::Permissions::from_mode(0o755)).unwrap();
std::fs::write(full.join("taxes.pdf"), "x").unwrap();
match Root::setup(home.path(), Some(&full), None) {
Err(RootError::Unsafe(why)) => assert!(why.contains("holds other things"), "{why}"),
x => panic!("{x:?}"),
}
assert_eq!(mode(&full), 0o755, "its permissions untouched");
assert!(!full.join("books").exists() && !full.join(MARKER_FILE).exists());
assert!(!platform_data_dir(home.path()).join(LOCATION_FILE).exists());
// An empty folder is fine.
let empty = home.path().join("empty");
std::fs::create_dir(&empty).unwrap();
std::fs::set_permissions(&empty, std::fs::Permissions::from_mode(0o700)).unwrap();
assert!(Root::setup(home.path(), Some(&empty), None).is_ok());
}
#[test]
fn a_root_another_user_could_swap_is_refused() {
let home = tmp("ancestor");
let open = home.path().join("open");
std::fs::create_dir(&open).unwrap();
std::fs::set_permissions(&open, std::fs::Permissions::from_mode(0o777)).unwrap();
match Root::setup(home.path(), Some(&open.join("books-root")), None) {
Err(RootError::Unsafe(why)) => assert!(why.contains("a folder above"), "{why}"),
x => panic!("{x:?}"),
}
// With the sticky bit (like /tmp), others can't swap our folders.
std::fs::set_permissions(&open, std::fs::Permissions::from_mode(0o1777)).unwrap();
let (root, _) = Root::setup(home.path(), Some(&open.join("books-root")), None).unwrap();
// And a parent opened up later is caught when the root is opened.
std::fs::set_permissions(&open, std::fs::Permissions::from_mode(0o777)).unwrap();
assert!(matches!(Root::open(root.path()), Err(RootError::Unsafe(_))));
std::fs::set_permissions(&open, std::fs::Permissions::from_mode(0o700)).unwrap();
}
#[test]
fn missing_folders_above_the_root_are_made_private() {
let home = tmp("parents");
let (root, _) = Root::setup(home.path(), None, None).unwrap();
assert_eq!(mode(&home.path().join(".local")), 0o700);
assert_eq!(mode(&home.path().join(".local/share")), 0o700);
assert_eq!(mode(&root.path().join(MARKER_FILE)), 0o600);
}
#[test]
fn a_planted_location_note_is_refused() {
let home = tmp("planted");
let data = platform_data_dir(home.path());
std::fs::create_dir_all(&data).unwrap();
let pointer = data.join(LOCATION_FILE);
std::fs::write(&pointer, format!("{}\n", home.path().join("x").display())).unwrap();
std::fs::set_permissions(&pointer, std::fs::Permissions::from_mode(0o666)).unwrap();
assert!(matches!(
Root::locate(home.path(), None),
Err(RootError::Unsafe(_))
));
assert!(matches!(
Root::setup(home.path(), None, None),
Err(RootError::Unsafe(_))
));
std::fs::remove_file(&pointer).unwrap();
std::os::unix::fs::symlink(home.path().join("elsewhere"), &pointer).unwrap();
assert!(matches!(
Root::locate(home.path(), None),
Err(RootError::Unsafe(_))
));
}
#[test]
fn a_new_root_is_named_plainly() {
let home = tmp("plain");
std::fs::create_dir(home.path().join("a")).unwrap();
// `..` anywhere is refused, where it exists or not.
for odd in ["a/../b", "new/../c"] {
assert!(
matches!(
Root::setup(home.path(), Some(&home.path().join(odd)), None),
Err(RootError::Unsafe(_))
),
"{odd}"
);
}
// Links in the folders above are resolved; the root's own name is kept.
std::os::unix::fs::symlink(home.path().join("a"), home.path().join("to-a")).unwrap();
let (root, _) =
Root::setup(home.path(), Some(&home.path().join("to-a/new/books")), None).unwrap();
assert_eq!(
root.path(),
std::fs::canonicalize(home.path())
.unwrap()
.join("a/new/books")
);
}
/// Set every folder under `dir` to `dirs` and every file to `files`.
fn chmod_tree(dir: &Path, dirs: u32, files: u32) {
for e in std::fs::read_dir(dir).unwrap() {
let p = e.unwrap().path();
if p.is_dir() {
chmod_tree(&p, dirs, files);
} else {
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(files)).unwrap();
}
}
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(dirs)).unwrap();
}
#[test]
fn adoption_leaves_a_read_only_source_as_it_was() {
let home = tmp("adopt-ro");
let (root, _) = Root::setup(home.path(), None, None).unwrap();
let old = tmp("adopt-ro-src");
drop(source_books(old.path(), true));
let before = snapshot_files(old.path());
chmod_tree(old.path(), 0o500, 0o400);
// Writable again however the test ends, so its folder can go.
struct Writable<'a>(&'a Path);
impl Drop for Writable<'_> {
fn drop(&mut self) {
chmod_tree(self.0, 0o700, 0o600);
}
}
let writable = Writable(old.path());
let report = root.adopt(old.path(), &id("acme"), &AdoptOptions::default());
drop(writable);
let report = report.unwrap();
assert!(!report.source_lock_created);
assert_eq!(snapshot_files(old.path()), before, "the lock file included");
}
#[test]
fn a_source_with_no_lock_file_gets_one_and_says_so() {
let home = tmp("adopt-nolock");
let (root, _) = Root::setup(home.path(), None, None).unwrap();
let old = tmp("adopt-nolock-src");
drop(source_books(old.path(), true));
std::fs::remove_file(old.path().join("wanderbooks.lock")).unwrap();
let report = root
.adopt(old.path(), &id("acme"), &AdoptOptions::default())
.unwrap();
assert!(report.source_lock_created);
}
#[test]
fn a_vault_or_key_that_isnt_there_stops_adoption() {
let home = tmp("adopt-novault");
let (root, _) = Root::setup(home.path(), None, None).unwrap();
let old = tmp("adopt-novault-src");
drop(source_books(old.path(), true));
for options in [
AdoptOptions {
vault_dir: Some(old.path().join("no-vault")),
..AdoptOptions::default()
},
AdoptOptions {
vault_key: Some(old.path().join("no.key")),
..AdoptOptions::default()
},
] {
match root.adopt(old.path(), &id("acme"), &options) {
Err(RootError::Adopt(why)) => assert!(why.contains("isn't there"), "{why}"),
x => panic!("{x:?}"),
}
}
assert!(root.list_books().unwrap().is_empty());
}
#[test]
fn every_stored_file_is_accounted_for() {
use sha2::{Digest, Sha256};
let home = tmp("adopt-blobs");
let (root, _) = Root::setup(home.path(), None, None).unwrap();
// A file the books name, missing: named in the refusal.
let missing = tmp("adopt-missing");
drop(source_books(missing.path(), true));
let gone = walk_files(&missing.path().join("blobs")).remove(0);
let sha = gone.file_name().unwrap().to_string_lossy().into_owned();
std::fs::remove_file(&gone).unwrap();
match root.adopt(missing.path(), &id("missing"), &AdoptOptions::default()) {
Err(RootError::Adopt(why)) => {
assert!(why.contains("aren't there") && why.contains(&sha), "{why}")
}
x => panic!("{x:?}"),
}
// A stored file the books don't name, and an unfinished write.
let extra = tmp("adopt-extra");
drop(source_books(extra.path(), true));
let orphan = b"stored, never named";
let osha = hex::encode(Sha256::digest(orphan));
let odir = extra
.path()
.join("blobs")
.join(&osha[0..2])
.join(&osha[2..4]);
std::fs::create_dir_all(&odir).unwrap();
std::fs::write(odir.join(&osha), orphan).unwrap();
std::fs::write(odir.join(format!("{osha}.tmp")), b"half").unwrap();
// Hidden files a desktop leaves (macOS's .DS_Store), anywhere in it.
std::fs::write(extra.path().join("blobs/.DS_Store"), b"finder").unwrap();
std::fs::write(odir.parent().unwrap().join(".DS_Store"), b"finder").unwrap();
std::fs::write(odir.join(".DS_Store"), b"finder").unwrap();
let report = root
.adopt(extra.path(), &id("extra"), &AdoptOptions::default())
.unwrap();
assert_eq!((report.blob_files, report.blobs_verified), (3, 2));
assert_eq!((report.unnamed_blobs, report.skipped_files), (1, 4));
let dir = root
.book_dir(&id("extra"))
.join("blobs")
.join(&osha[0..2])
.join(&osha[2..4]);
assert!(dir.join(&osha).is_file() && !dir.join(format!("{osha}.tmp")).exists());
// A stray file where only stored files belong: refused.
let stray = tmp("adopt-stray");
drop(source_books(stray.path(), true));
std::fs::write(stray.path().join("blobs/notes.txt"), "x").unwrap();
assert!(matches!(
root.adopt(stray.path(), &id("stray"), &AdoptOptions::default()),
Err(RootError::Adopt(_))
));
assert_eq!(root.list_books().unwrap(), vec![id("extra")]);
}
#[test]
fn books_from_a_newer_wanderbooks_are_refused() {
let home = tmp("adopt-newer");
let (root, _) = Root::setup(home.path(), None, None).unwrap();
let old = tmp("adopt-newer-src");
drop(source_books(old.path(), true));
append_newer_line(old.path());
let err = root
.adopt(old.path(), &id("acme"), &AdoptOptions::default())
.unwrap_err();
assert!(
matches!(err.store_error(), Some(StoreError::NewerJournal { .. })),
"{err:?}"
);
assert!(root.list_books().unwrap().is_empty());
}
#[test]
fn an_adoption_that_never_finished_is_cleared_away() {
let home = tmp("adopt-stale");
let (root, _) = Root::setup(home.path(), None, None).unwrap();
let stale = root.path().join("books/.adopt-acme-999999");
std::fs::create_dir(&stale).unwrap();
std::fs::write(stale.join("journal.jsonl"), "half a copy").unwrap();
let old = tmp("adopt-stale-src");
drop(source_books(old.path(), true));
root.adopt(old.path(), &id("acme"), &AdoptOptions::default())
.unwrap();
assert!(!stale.exists());
assert_eq!(
std::fs::read_dir(root.path().join("books"))
.unwrap()
.count(),
1
);
}
#[test]
fn settings_given_for_books_that_keep_their_own_are_reported_unused() {
let home = tmp("adopt-ignored");
let (root, _) = Root::setup(home.path(), None, None).unwrap();
let old = tmp("adopt-ignored-src");
let s = source_books(old.path(), true);
let head = s.head();
drop(s);
let report = root
.adopt(
old.path(),
&id("acme"),
&AdoptOptions {
settings: Some((vec![set("company.name", json!("Other"))], src("x"))),
..AdoptOptions::default()
},
)
.unwrap();
assert!(report.settings_ignored);
assert_eq!((report.head, report.settings_imported), (head, 0));
}
#[test]
fn no_refusal_names_the_root() {
let home = tmp("hidden");
let (root, _) = Root::setup(home.path(), None, None).unwrap();
let path = root.path().display().to_string();
drop(root.create_book(&id("acme"), Currency::USD).unwrap());
let open = Store::open(&root.book_dir(&id("acme"))).unwrap();
let mut errors = vec![
refused(root.create_book(&id("acme"), Currency::USD)).to_string(),
root.adopt(
&root.book_dir(&id("acme")),
&id("x"),
&AdoptOptions::default(),
)
.unwrap_err()
.to_string(),
];
drop(open);
let books = root.path().join("books");
std::fs::set_permissions(&books, std::fs::Permissions::from_mode(0o755)).unwrap();
errors.push(root.list_books().unwrap_err().to_string());
errors.push(refused(root.create_book(&id("beta"), Currency::USD)).to_string());
std::fs::set_permissions(&books, std::fs::Permissions::from_mode(0o700)).unwrap();
std::fs::set_permissions(root.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
errors.push(Root::open(root.path()).unwrap_err().to_string());
std::fs::set_permissions(root.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
for e in errors {
assert!(!e.contains(&path), "names the root: {e}");
assert!(!e.is_empty());
}
}
#[test]
fn setup_changes_nothing_under_a_folder_another_user_could_swap() {
let home = tmp("order");
let open = home.path().join("open");
std::fs::create_dir(&open).unwrap();
let existing = open.join("existing");
std::fs::create_dir(&existing).unwrap();
std::fs::set_permissions(&existing, std::fs::Permissions::from_mode(0o755)).unwrap();
std::fs::set_permissions(&open, std::fs::Permissions::from_mode(0o777)).unwrap();
// An existing folder there: refused before it is tightened or marked.
assert!(matches!(
Root::setup(home.path(), Some(&existing), None),
Err(RootError::Unsafe(_))
));
assert_eq!(mode(&existing), 0o755);
assert!(std::fs::read_dir(&existing).unwrap().next().is_none());
// A new one there: refused before any folder is made.
assert!(matches!(
Root::setup(home.path(), Some(&open.join("new/books")), None),
Err(RootError::Unsafe(_))
));
assert!(!open.join("new").exists());
std::fs::set_permissions(&open, std::fs::Permissions::from_mode(0o700)).unwrap();
}
#[test]
fn a_root_that_cant_be_opened_counts_as_holding_books() {
let home = tmp("loosened");
let (root, _) = Root::setup(home.path(), None, None).unwrap();
drop(root.create_book(&id("acme"), Currency::USD).unwrap());
std::fs::set_permissions(root.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
let elsewhere = home.path().join("elsewhere");
match Root::setup(home.path(), Some(&elsewhere), None) {
Err(RootError::Unsafe(why)) => assert!(why.contains("may hold books"), "{why}"),
x => panic!("{x:?}"),
}
assert!(!elsewhere.exists());
// Via XDG_DATA_HOME, the refusal says so.
match Root::setup(home.path(), None, Some(&home.path().join("xdg"))) {
Err(RootError::Unsafe(why)) => assert!(why.contains("XDG_DATA_HOME"), "{why}"),
x => panic!("{x:?}"),
}
// Setup with nothing given makes it private again, books and all.
std::fs::set_permissions(root.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
}
#[test]
fn a_group_folder_of_this_account_s_own_private_group_is_fine() {
use std::os::unix::fs::MetadataExt;
let home = tmp("upg");
let parent = home.path().join("share");
std::fs::create_dir(&parent).unwrap();
std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o775)).unwrap();
let gid = std::fs::metadata(&parent).unwrap().gid();
// SAFETY: getgid has no preconditions.
let primary = unsafe { libc::getgid() };
let result = Root::setup(home.path(), Some(&parent.join("books")), None);
if gid == primary && private_group(primary) {
assert!(result.is_ok(), "{:?}", result.err());
} else {
assert!(matches!(result, Err(RootError::Unsafe(_))));
}
}
/// The test's own view of a private group: the primary group, named as
/// the login is, no members listed (from /etc/group and $USER, when there).
fn private_group(gid: u32) -> bool {
let Ok(user) = std::env::var("USER") else {
return false;
};
std::fs::read_to_string("/etc/group").is_ok_and(|groups| {
groups.lines().any(|l| {
let f: Vec<&str> = l.split(':').collect();
f.len() >= 4 && f[0] == user && f[2] == gid.to_string() && f[3].is_empty()
})
})
}
#[test]
fn a_book_id_prints_as_itself() {
assert_eq!(id("acme-2026").to_string(), "acme-2026");
assert_eq!(id("acme-2026").as_str(), "acme-2026");
}
/// Something put under a source's `blobs/` for a test.
type Plant = Box<dyn Fn(&Path)>;
#[test]
fn only_stored_files_where_the_books_keep_them_are_adopted() {
use sha2::{Digest, Sha256};
let home = tmp("adopt-strays");
let (root, _) = Root::setup(home.path(), None, None).unwrap();
let bytes = b"a real stored file";
let sha = hex::encode(Sha256::digest(bytes));
// Each case: something under blobs/ that isn't where a stored file
// sits. Every one is refused, with the same reason.
let cases: Vec<(&str, Plant)> = vec![
(
"a folder not named by two hex digits",
Box::new(|b: &Path| std::fs::create_dir(b.join("zz")).unwrap()),
),
(
"a folder named by three hex digits",
Box::new(|b: &Path| std::fs::create_dir(b.join("abc")).unwrap()),
),
(
"a file where a folder belongs",
Box::new(|b: &Path| std::fs::write(b.join("ab"), "x").unwrap()),
),
(
"a second-level folder not named by two hex digits",
Box::new(|b: &Path| std::fs::create_dir_all(b.join("ab/zz")).unwrap()),
),
(
"a second-level file where a folder belongs",
Box::new(|b: &Path| {
std::fs::create_dir_all(b.join("ab")).unwrap();
std::fs::write(b.join("ab/cd"), "x").unwrap()
}),
),
(
"a stored file in the wrong folder",
Box::new(move |b: &Path| {
let wrong = if sha.starts_with("00") { "11" } else { "00" };
std::fs::create_dir_all(b.join(wrong).join(&sha[2..4])).unwrap();
std::fs::write(b.join(wrong).join(&sha[2..4]).join(&sha), bytes).unwrap()
}),
),
(
"a file named by 64 characters that aren't hex",
Box::new(|b: &Path| {
std::fs::create_dir_all(b.join("ab/cd")).unwrap();
std::fs::write(b.join("ab/cd").join(format!("ab{}", "z".repeat(62))), "x").unwrap()
}),
),
];
for (i, (what, plant)) in cases.iter().enumerate() {
let src = tmp(&format!("adopt-stray-{i}"));
drop(source_books(src.path(), true));
plant(&src.path().join("blobs"));
match root.adopt(src.path(), &id(&format!("s{i}")), &AdoptOptions::default()) {
Err(RootError::Adopt(why)) => {
assert!(why.contains("isn't a stored file"), "{what}: {why}")
}
x => panic!("{what}: {x:?}"),
}
}
assert!(root.list_books().unwrap().is_empty());
}
#[test]
fn errors_other_than_not_found_are_never_taken_for_it() {
let home = tmp("not-not-found");
// Opening a root under a file: the error, not "no root yet".
let file = home.path().join("file");
std::fs::write(&file, "x").unwrap();
assert!(matches!(
Root::open(&file.join("root")),
Err(RootError::Io(..))
));
// The data folder is a file: finding the root fails, never falls back.
let odd = tmp("not-not-found-home");
std::fs::create_dir(odd.path().join(".local")).unwrap();
std::fs::write(odd.path().join(".local/share"), "x").unwrap();
assert!(matches!(
Root::locate(odd.path(), None),
Err(RootError::Io(..))
));
// A source whose lock file can't be read: refused, never recreated.
let (root, _) = Root::setup(home.path(), None, None).unwrap();
let src = tmp("not-not-found-src");
drop(source_books(src.path(), true));
let lock = src.path().join("wanderbooks.lock");
std::fs::set_permissions(&lock, std::fs::Permissions::from_mode(0o200)).unwrap();
assert!(matches!(
root.adopt(src.path(), &id("acme"), &AdoptOptions::default()),
Err(RootError::Io(..))
));
std::fs::set_permissions(&lock, std::fs::Permissions::from_mode(0o600)).unwrap();
assert!(root.list_books().unwrap().is_empty());
}
#[test]
fn an_adoption_preview_reads_the_source_and_writes_nothing() {
let home = tmp("preview");
let (root, _) = Root::setup(home.path(), None, None).unwrap();
let old = tmp("preview-src");
let s = source_books(old.path(), false);
let head = s.head();
drop(s);
// No lock file beside the journal: the preview must not make one.
let _ = std::fs::remove_file(old.path().join("wanderbooks.lock"));
let before = snapshot_files(old.path());
let found = root.adopt_preview(old.path()).unwrap();
assert_eq!(found.head, head);
assert_eq!(found.currency, Currency::USD);
assert!(!found.keeps_settings);
assert_eq!(
snapshot_files(old.path()),
before,
"the preview wrote to the source"
);
assert!(root.list_books().unwrap().is_empty());
// Books that keep their own settings say so.
let kept = tmp("preview-kept");
drop(source_books(kept.path(), true));
assert!(root.adopt_preview(kept.path()).unwrap().keeps_settings);
// The preview refuses what adoption refuses, naming no root.
let book = root
.create_book(&BookId::parse("acme").unwrap(), Currency::USD)
.unwrap();
drop(book);
let root_text = root.path().display().to_string();
for bad in [
root.book_dir(&BookId::parse("acme").unwrap()),
root.path().to_path_buf(),
home.path().join("missing"),
home.path().to_path_buf(),
] {
let e = root.adopt_preview(&bad).unwrap_err();
assert!(matches!(e, RootError::Adopt(_)), "{bad:?}: {e:?}");
assert!(!e.to_string().contains(&root_text), "{e}");
}
// A cut-off last line is refused, as adoption refuses it.
let cut = tmp("preview-cut");
drop(source_books(cut.path(), false));
let journal = cut.path().join(JOURNAL_FILE);
let mut raw = std::fs::read(&journal).unwrap();
raw.pop();
std::fs::write(&journal, raw).unwrap();
assert!(
matches!(root.adopt_preview(cut.path()), Err(RootError::Adopt(why)) if why.contains("cut off"))
);
}
#[test]
fn a_book_folder_is_made_private_and_a_shared_one_refused() {
let home = tmp("bookfolder");
let (root, _) = Root::setup(home.path(), None, None).unwrap();
drop(
root.create_book(&BookId::parse("acme").unwrap(), Currency::USD)
.unwrap(),
);
let book = root.book_dir(&BookId::parse("acme").unwrap());
let keys = ledger_store::root::private_book_folder(&book, "keys").unwrap();
assert_eq!(keys, book.join("keys"));
assert_eq!(mode(&keys), 0o700);
// Again: already there, fine.
assert_eq!(
ledger_store::root::private_book_folder(&book, "keys").unwrap(),
keys
);
// Ours, but others could enter it: made private again.
std::fs::set_permissions(&keys, std::fs::Permissions::from_mode(0o755)).unwrap();
ledger_store::root::private_book_folder(&book, "keys").unwrap();
assert_eq!(mode(&keys), 0o700);
std::os::unix::fs::symlink(home.path(), book.join("vault")).unwrap();
assert!(ledger_store::root::private_book_folder(&book, "vault").is_err());
}
#[test]
fn the_run_folder_is_private() {
let home = tmp("run");
let (root, _) = Root::setup(home.path(), None, None).unwrap();
let run = root.run_dir().unwrap();
assert_eq!(run, root.path().join("run"));
assert_eq!(mode(&run), 0o700);
std::fs::remove_dir(&run).unwrap();
assert_eq!(mode(&root.run_dir().unwrap()), 0o700, "remade private");
std::fs::set_permissions(&run, std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(matches!(root.run_dir(), Err(RootError::Unsafe(_))));
}
#[test]
fn inside_a_root_is_known_by_its_marker() {
let home = tmp("inside");
let (root, _) = Root::setup(home.path(), None, None).unwrap();
use ledger_store::root::inside_a_root;
assert!(inside_a_root(root.path()));
assert!(inside_a_root(&root.path().join("books/acme")));
assert!(inside_a_root(&root.path().join("books/not/yet/made")));
assert!(!inside_a_root(home.path()));
// A path relative to where the process runs is taken from there.
let here = std::env::current_dir().unwrap();
let up = "../".repeat(here.components().count() - 1);
let relative = PathBuf::from(format!(
"{up}{}",
root.path()
.join("books/acme")
.display()
.to_string()
.trim_start_matches('/')
));
assert!(relative.is_relative());
assert!(inside_a_root(&relative));
let outside = PathBuf::from(format!(
"{up}{}",
home.path().display().to_string().trim_start_matches('/')
));
assert!(!inside_a_root(&outside));
assert!(!inside_a_root(&home.path().join("elsewhere")));
// Through a link, too: it is where the path leads that counts.
let link = home.path().join("link");
std::os::unix::fs::symlink(root.path().join("books"), &link).unwrap();
assert!(inside_a_root(&link));
assert!(!root.path().join(MARKER_FILE).is_dir());
}
#[test]
fn books_that_changed_since_the_preview_are_not_adopted() {
let home = tmp("expect");
let (root, _) = Root::setup(home.path(), None, None).unwrap();
let old = tmp("expect-src");
drop(source_books(old.path(), true));
let previewed = root.adopt_preview(old.path()).unwrap().head;
// The old server writes one more event after the preview.
let mut s = Store::open(old.path()).unwrap();
s.add_account("6100", "Rent", AccountType::Expense, None, false)
.unwrap();
let now = s.head();
// While it holds them, a preview is refused as adoption would be.
assert!(matches!(
root.adopt_preview(old.path()),
Err(RootError::Store(_, StoreError::Locked(_)))
));
drop(s);
let before = snapshot_files(old.path());
let e = root
.adopt(
old.path(),
&id("acme"),
&AdoptOptions {
expect_head: Some(previewed),
..AdoptOptions::default()
},
)
.unwrap_err();
assert!(
matches!(&e, RootError::Adopt(why) if why.contains("changed since the preview")),
"{e:?}"
);
assert!(root.list_books().unwrap().is_empty());
assert_eq!(snapshot_files(old.path()), before);
// Previewed again, it goes ahead.
let report = root
.adopt(
old.path(),
&id("acme"),
&AdoptOptions {
expect_head: Some(now.clone()),
..AdoptOptions::default()
},
)
.unwrap();
assert_eq!(report.source_head, now);
}
/// A backup of `store`'s books (in `dir`) in a folder target under
/// `home`: its address.
fn backed_up(home: &Path, dir: &Path, store: &Store) -> Option<String> {
use ledger_store::backup::{self, Git, Target};
let git = Git::find().ok()?;
backup::commit(&git, dir, "acme", store.head()).unwrap();
let folder = home.join("disk");
std::fs::create_dir_all(&folder).unwrap();
let target = Target::Folder(folder);
backup::push(&git, dir, "acme", &target, true).unwrap();
Some(target.source("acme"))
}
#[test]
fn a_restore_never_moves_books_something_holds() {
use ledger_store::backup::Git;
let home = tmp("restore-held");
let (root, _) = Root::setup(home.path(), None, None).unwrap();
let acme = id("acme");
let held = root.create_book(&acme, Currency::USD).unwrap();
let dir = root.book_dir(&acme);
let source = backed_up(home.path(), &dir, &held).expect("git is installed");
let git = Git::find().unwrap();
let preview = root.restore_preview(&git, &source, &acme).unwrap();
assert!(preview.exists);
// Open (its writer lock held): refused, nothing moved.
let e = root
.restore(&git, &source, &acme, &preview.head, true)
.unwrap_err();
assert!(e.to_string().contains("are open"), "{e}");
assert!(dir.join(JOURNAL_FILE).is_file());
assert!(
!root.path().join("legacy").exists()
|| std::fs::read_dir(root.path().join("legacy"))
.unwrap()
.count()
== 0
);
// Not over books without --replace; nothing to replace that isn't.
let e = root
.restore(&git, &source, &acme, &preview.head, false)
.unwrap_err();
assert!(matches!(e, RootError::Exists(_)), "{e}");
let e = root
.restore(&git, &source, &id("nobody"), &preview.head, true)
.unwrap_err();
assert!(e.to_string().contains("no books called"), "{e}");
// Let go: replaced, the old kept whole.
drop(held);
let r = root
.restore(&git, &source, &acme, &preview.head, true)
.unwrap();
let kept = root.path().join("legacy").join(r.replaced.unwrap());
assert!(kept.join(JOURNAL_FILE).is_file());
assert_eq!(r.head, preview.head);
// Nothing of the restore is left behind in books/.
let left: Vec<_> = std::fs::read_dir(root.path().join("books"))
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.filter(|n| n.starts_with('.'))
.collect();
assert!(left.is_empty(), "{left:?}");
}