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
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-20 11:14:21 -06:00
1 parent 5bc0633cdd
commit 019d8d76a3
29 files changed
+6443 -136

No files matched your search

+194 -4
View File
@@ -14,9 +14,9 @@
//! 1. A command that needs the server holds a lease while it runs, and
//! lets go when it ends.
//! 2. Nothing printed names the data root.
//! 3. Adoption is previewed first and goes ahead only on a yes at the
//! terminal, or --yes; a vault beside the books must be named (or
//! --no-vault given): it is never silently left behind.
//! 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 before anything is said of it,
//! and its text is printed only by `token print`; one that can't be
//! kept is revoked again. A token revoked is forgotten by the keyring
@@ -392,12 +392,16 @@ fn books_usage(prog: &str) {
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!(" 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!(
" 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/)"
);
}
/// `wanderbooks books list|create|adopt`.
/// `wanderbooks books list|create|adopt|restore`.
pub fn books(
prog: &str,
args: impl IntoIterator<Item = String>,
@@ -413,6 +417,7 @@ pub fn books(
let mut no_vault = false;
let mut skip_unknown = false;
let mut yes = false;
let mut replace = false;
let mut args = args.into_iter();
while let Some(arg) = args.next() {
if common.take(&arg, &mut args)? {
@@ -428,6 +433,7 @@ pub fn books(
"--vault-key" => vault_key = Some(PathBuf::from(value("a file")?)),
"--no-vault" => no_vault = true,
"--skip-unknown" => skip_unknown = true,
"--replace" => replace = true,
"--yes" => yes = true,
"--help" | "-h" => {
books_usage(prog);
@@ -471,6 +477,7 @@ pub fn books(
yes,
);
}
["restore", source] => return restore(&common, source, id, replace, yes),
_ => {
books_usage(prog);
return Err(exit(2));
@@ -514,6 +521,172 @@ pub fn books(
}
}
/// 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))
};
// 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.to_string(),
};
// One preview (under the id given, or any: it only reports), then the
// id: given, or else the id of the books the backup holds, or else the
// repository's name.
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,
},
) {
Ok(Response::RestorePreview(p)) => p,
Ok(Response::Refused(why)) | Err(why) => return fail(format!("{source}: {why}")),
Ok(other) => return fail(format!("the books server answered oddly: {other:?}")),
};
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 {
eprintln!("error: name the restored books with --id ID");
return Err(exit(2));
};
let exists = match with_server(common, Request::List) {
Ok(Response::Books(books)) => books.iter().any(|b| b.id == id),
Ok(Response::Refused(why)) | Err(why) => return fail(why),
Ok(other) => return fail(format!("the books server answered oddly: {other:?}")),
};
if exists && !replace {
return fail(format!(
"there are already books called \"{id}\": restore them under another id (--id), or replace them (--replace; they are kept in legacy/)"
));
}
if !exists && replace {
return fail(format!(
"there are no books called \"{id}\" to replace: nothing was restored"
));
}
let request = |expect_head: Option<(u64, String)>| Request::Restore {
id: id.clone(),
source: source.clone(),
replace,
confirmed: expect_head.is_some(),
expect_head,
};
println!(
"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 {
println!("It is a backup of the books \"{origin}\".");
}
if replace {
println!(
"It replaces the books \"{id}\": they are closed and kept whole in the legacy folder, not deleted."
);
} else {
println!("It comes back as new books \"{id}\", beside any others.");
}
match preview.origin.as_deref() {
Some(o) if o == id => {}
Some(o) => println!(
"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}\" (--id {o}); else name a repository of their own."
),
None => println!(
"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."
),
}
match confirmed("Restore it?", yes) {
Ok(true) => {}
Ok(false) => {
println!("Not restored.");
return Ok(());
}
Err(why) => return fail(format!("not restored: {why}")),
}
match with_server(common, request(Some(preview.head.clone()))) {
Ok(Response::Restored(r)) => {
println!(
"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 {
println!("The books it replaced are kept in the legacy folder as {kept}.");
}
if let Some(why) = r.problem {
return fail(format!(
"the restored books didn't open: {why}. Closing the books and opening them again tries again."
));
}
Ok(())
}
Ok(Response::Refused(why)) | Err(why) => fail(format!("{source}: {why}")),
Ok(other) => fail(format!("the books server answered oddly: {other:?}")),
}
}
/// The files an adoption brings in with the books.
struct AdoptFiles {
toml: Option<PathBuf>,
@@ -1743,6 +1916,23 @@ mod tests {
}
}
#[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());
+3 -1
View File
@@ -162,7 +162,9 @@ fn answer(ask: HelperAsk, root: Option<&Path>, knobs: &Knobs) -> Answer {
HelperAsk::OpenWith { .. } => {
Answer::Refused("opening documents comes with the viewer's menu".into())
}
HelperAsk::StageBackup => Answer::Refused("restoring isn't built yet".into()),
HelperAsk::StageBackup => Answer::Refused(
"restore from a terminal for now: `wanderbooks books restore BACKUP`".into(),
),
HelperAsk::StartUpdater => Answer::Refused("updates come with the installers".into()),
}
}
+16 -2
View File
@@ -657,6 +657,15 @@ impl Host {
if self.explicit_root {
command.arg("--root").arg(self.root.path());
}
let d = Knobs::default();
for (flag, now, default) in [
("--backup-quiet", self.knobs.backup_quiet, d.backup_quiet),
("--drill-first", self.knobs.drill_first, d.drill_first),
] {
if now != default {
command.arg(flag).arg(now.as_millis().to_string());
}
}
let mut child = command
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
@@ -701,11 +710,16 @@ impl Host {
None,
),
Intent::BackUpNow { .. } => (
HostMsg::Refused("backing up isn't built yet (it comes later in P2)".into()),
HostMsg::Refused(
"the books back themselves up; to back up now, ask your agent (books.back_up_now)"
.into(),
),
None,
),
Intent::Restore { .. } => (
HostMsg::Refused("restoring isn't built yet (it comes later in P2)".into()),
HostMsg::Refused(
"restore from a terminal for now: `wanderbooks books restore BACKUP`".into(),
),
None,
),
Intent::ConsentFormatUpgrade { .. } => (
+17 -1
View File
@@ -30,6 +30,12 @@ pub struct Knobs {
/// Keep agents' tokens in files in the root's `run/`, not the OS
/// keyring (tests: tokens.rs INVARIANT 3). `file_keyring=1`.
pub file_keyring: bool,
/// How long the books stay unwritten before the server backs them up
/// (plan 3.7: 30 s), passed to the server as `--backup-quiet`.
pub backup_quiet: Duration,
/// How long after books open the server first looks for a drill due
/// (60 s), passed to the server as `--drill-first`.
pub drill_first: Duration,
}
impl Default for Knobs {
@@ -43,6 +49,8 @@ impl Default for Knobs {
queue_wait: Duration::from_secs(60),
open_wait: Duration::from_secs(120),
file_keyring: false,
backup_quiet: Duration::from_secs(30),
drill_first: Duration::from_secs(60),
}
}
}
@@ -78,6 +86,8 @@ impl Knobs {
"init_wait" => k.init_wait = d,
"queue_wait" => k.queue_wait = d,
"open_wait" => k.open_wait = d,
"backup_quiet" => k.backup_quiet = d,
"drill_first" => k.drill_first = d,
other => return Err(format!("{FLAG}: there is no knob {other:?}")),
}
}
@@ -97,6 +107,8 @@ impl Knobs {
("init_wait", self.init_wait, d.init_wait),
("queue_wait", self.queue_wait, d.queue_wait),
("open_wait", self.open_wait, d.open_wait),
("backup_quiet", self.backup_quiet, d.backup_quiet),
("drill_first", self.drill_first, d.drill_first),
] {
if now != default {
parts.push(format!("{name}={}", now.as_millis()));
@@ -131,6 +143,8 @@ mod tests {
assert_eq!(k.exe_poll, Duration::from_secs(5));
assert_eq!(k.server_delay, Duration::ZERO);
assert_eq!(k.open_wait, Duration::from_secs(120));
assert_eq!(k.backup_quiet, Duration::from_secs(30));
assert_eq!(k.drill_first, Duration::from_secs(60));
assert_eq!(k.to_arg(), None);
assert!(k.args().is_empty());
assert_eq!(Knobs::parse("").unwrap(), k);
@@ -139,7 +153,7 @@ mod tests {
#[test]
fn knobs_pass_on_exactly() {
let k = Knobs::parse(
"close_after=500,backoff=20,server_delay=3000,init_wait=100,queue_wait=900,exe_poll=50,open_wait=70",
"close_after=500,backoff=20,server_delay=3000,init_wait=100,queue_wait=900,exe_poll=50,open_wait=70,backup_quiet=40,drill_first=50",
)
.unwrap();
assert_eq!(k.close_after, Duration::from_millis(500));
@@ -149,6 +163,8 @@ mod tests {
assert_eq!(k.queue_wait, Duration::from_millis(900));
assert_eq!(k.exe_poll, Duration::from_millis(50));
assert_eq!(k.open_wait, Duration::from_millis(70));
assert_eq!(k.backup_quiet, Duration::from_millis(40));
assert_eq!(k.drill_first, Duration::from_millis(50));
let args = k.args();
assert_eq!(args[0], FLAG);
assert_eq!(Knobs::parse(&args[1]).unwrap(), k);
+25 -33
View File
@@ -5,7 +5,8 @@
//! the server, through one socket that accepts nothing else.
//! SOURCE: plan/served.md 3.9 ("Who holds them", "Three local channels":
//! leases record their kind and PID; exactly five owner intents;
//! consent and a replacing restore carry the owner passphrase;
//! consent carries the owner passphrase (a replacing restore no
//! longer does: D-31, the books it replaces are kept in legacy/);
//! "Closing": the host says why on each lease; protocol versions N
//! and N-1).
//! INVARIANTS:
@@ -13,11 +14,10 @@
//! connection (the lease lasts until it closes), anything else is
//! answered once and the connection closed.
//! 2. Exactly five intents exist: close, back_up_now, new_books,
//! restore (a staged backup's id, never a path) and
//! consent_format_upgrade; any other request is refused.
//! 3. consent_format_upgrade, and a restore that replaces a book, carry
//! the owner passphrase; without one they are refused before
//! anything is done.
//! restore (a backup's address or folder, as `books restore` takes
//! it) and consent_format_upgrade; any other request is refused.
//! 3. consent_format_upgrade carries the owner passphrase; without one
//! it is refused before anything is done.
//! 4. Before the host ends a lease it sends the reason; a lease that
//! ends with no reason means the host is gone.
//! 5. Each side accepts its own protocol version and the one before.
@@ -120,16 +120,17 @@ pub enum Intent {
/// Their base currency.
currency: String,
},
/// Restore a staged backup, as its own book or replacing one.
/// Restore a backup, as its own book or replacing one (kept in
/// legacy/).
Restore {
/// The staged backup's id (never a path).
staged: String,
/// The book it replaces, if it replaces one.
/// The backup: a repository's address, or its folder (`<book>.git`
/// in the backup folder).
source: String,
/// The books' id.
id: String,
/// Replace the books of that id.
#[serde(default)]
replace: Option<String>,
/// The owner passphrase: needed to replace (INVARIANT 3).
#[serde(default)]
passphrase: Option<String>,
replace: bool,
},
/// Let the server upgrade a book's journal format.
ConsentFormatUpgrade {
@@ -151,13 +152,6 @@ impl Intent {
"consenting to a format upgrade needs the owner passphrase, typed in the app"
.into(),
),
Intent::Restore {
replace: Some(_),
passphrase,
..
} if missing(passphrase) => Err(
"a restore that replaces books needs the owner passphrase, typed in the app".into(),
),
_ => Ok(()),
}
}
@@ -288,7 +282,7 @@ mod tests {
json!("close"),
json!({"back_up_now": {"book": "acme"}}),
json!({"new_books": {"id": "acme", "currency": "USD"}}),
json!({"restore": {"staged": "s1"}}),
json!({"restore": {"source": "/backups/acme.git", "id": "acme"}}),
json!({"consent_format_upgrade": {"book": "acme", "passphrase": "x"}}),
] {
let h = parse(json!({"proto": 1, "intent": intent})).unwrap();
@@ -297,7 +291,8 @@ mod tests {
for bad in [
json!({"proto": 1, "intent": "delete"}),
json!({"proto": 1, "intent": {"restore": {"path": "/x"}}}),
json!({"proto": 1, "intent": {"restore": {"staged": "s1", "path": "/x"}}}),
json!({"proto": 1, "intent": {"restore": {"source": "/x", "id": "a", "path": "/x"}}}),
json!({"proto": 1, "intent": {"restore": {"staged": "s1"}}}),
json!({"proto": 1, "intent": {"run": {"command": "sh"}}}),
json!({"proto": 1, "exec": "sh"}),
json!({"proto": 1}),
@@ -322,7 +317,7 @@ mod tests {
}
#[test]
fn consent_and_a_replacing_restore_need_the_passphrase() {
fn only_consent_needs_the_passphrase() {
let consent = |p: Option<&str>| Intent::ConsentFormatUpgrade {
book: "acme".into(),
passphrase: p.map(str::to_string),
@@ -330,16 +325,13 @@ mod tests {
assert!(consent(None).check_passphrase().is_err());
assert!(consent(Some("")).check_passphrase().is_err());
assert!(consent(Some("pw")).check_passphrase().is_ok());
let restore = |replace: Option<&str>, p: Option<&str>| Intent::Restore {
staged: "s1".into(),
replace: replace.map(str::to_string),
passphrase: p.map(str::to_string),
};
assert!(restore(None, None).check_passphrase().is_ok());
assert!(restore(Some("acme"), None).check_passphrase().is_err());
assert!(restore(Some("acme"), Some("")).check_passphrase().is_err());
assert!(restore(Some("acme"), Some("pw")).check_passphrase().is_ok());
for other in [
// D-31: replacing keeps the old books in legacy/; no passphrase.
Intent::Restore {
source: "/backups/acme.git".into(),
id: "acme".into(),
replace: true,
},
Intent::Close,
Intent::BackUpNow { book: "a".into() },
Intent::NewBooks {
+81 -7
View File
@@ -9,8 +9,11 @@
//! 1. The host's environment is a fixed list: PATH (fixed), HOME (from
//! the account's record, never the opener's), and XDG_RUNTIME_DIR
//! and DBUS_SESSION_BUS_ADDRESS only when they point at this
//! account's own private runtime folder. Nothing else passes (a
//! scope's own identifiers, such as INVOCATION_ID, are systemd's).
//! account's own private runtime folder, and SSH_AUTH_SOCK only when
//! it names a socket this account owns (the owner's ssh agent, so the
//! server's backups reach a private git repository, D-30). Nothing
//! else passes (a scope's own identifiers, such as INVOCATION_ID, are
//! systemd's).
//! 2. Its standard input and output are null, its errors go to
//! run/host.log (0600); it starts a new session with umask 077. Every
//! other descriptor is closed (std opens them close-on-exec, and the
@@ -112,8 +115,20 @@ fn session_bus(runtime: &Path) -> Option<String> {
}
}
/// The host's environment (INVARIANT 1).
fn host_env(home: &Path) -> Vec<(String, std::ffi::OsString)> {
/// The ssh agent's socket named in `named`, if it is a socket this
/// account owns at an absolute path (INVARIANT 1).
fn own_agent(named: Option<&std::ffi::OsStr>) -> Option<std::ffi::OsString> {
use std::os::unix::fs::FileTypeExt;
let path = Path::new(named?);
(path.is_absolute()
&& std::fs::symlink_metadata(path)
.is_ok_and(|m| m.file_type().is_socket() && m.uid() == own_uid()))
.then(|| path.as_os_str().to_os_string())
}
/// The host's environment (INVARIANT 1), given the opener's
/// `SSH_AUTH_SOCK`.
fn host_env(home: &Path, ssh_agent: Option<&std::ffi::OsStr>) -> Vec<(String, std::ffi::OsString)> {
let mut env = vec![
("PATH".to_string(), PATH.into()),
("HOME".to_string(), home.as_os_str().to_os_string()),
@@ -124,6 +139,9 @@ fn host_env(home: &Path) -> Vec<(String, std::ffi::OsString)> {
}
env.push(("XDG_RUNTIME_DIR".into(), runtime.into_os_string()));
}
if let Some(agent) = own_agent(ssh_agent) {
env.push(("SSH_AUTH_SOCK".into(), agent));
}
env
}
@@ -199,7 +217,10 @@ pub fn start_host(root: Option<&Path>, knobs: &Knobs) -> Result<(), String> {
};
c.args(&args)
.env_clear()
.envs(host_env(&home))
.envs(host_env(
&home,
std::env::var_os("SSH_AUTH_SOCK").as_deref(),
))
.current_dir("/")
.stdin(Stdio::null())
.stdout(Stdio::null())
@@ -357,12 +378,17 @@ mod tests {
#[test]
fn the_host_environment_carries_nothing_else() {
let env = host_env(Path::new("/home/someone"));
let env = host_env(Path::new("/home/someone"), None);
let names: Vec<&str> = env.iter().map(|(n, _)| n.as_str()).collect();
assert_eq!(&names[..2], ["PATH", "HOME"]);
for n in &names[2..] {
assert!(
["XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS"].contains(n),
[
"XDG_RUNTIME_DIR",
"DBUS_SESSION_BUS_ADDRESS",
"SSH_AUTH_SOCK"
]
.contains(n),
"{n}"
);
}
@@ -370,6 +396,54 @@ mod tests {
assert_eq!(env[1].1, "/home/someone");
}
#[test]
fn only_an_own_ssh_agent_socket_passes() {
let dir = tempfile::tempdir().unwrap();
let sock = dir.path().join("agent.sock");
let _listener = std::os::unix::net::UnixListener::bind(&sock).unwrap();
assert_eq!(
own_agent(Some(sock.as_os_str())),
Some(sock.clone().into_os_string())
);
// It reaches the host's environment, once.
let env = host_env(Path::new("/home/someone"), Some(sock.as_os_str()));
let agents: Vec<_> = env.iter().filter(|(n, _)| n == "SSH_AUTH_SOCK").collect();
assert_eq!(agents.len(), 1);
assert_eq!(agents[0].1, sock.as_os_str());
// A link to it is not it (whoever made the link chose where it
// points).
let link = dir.path().join("link.sock");
std::os::unix::fs::symlink(&sock, &link).unwrap();
assert_eq!(own_agent(Some(link.as_os_str())), None);
let env = host_env(Path::new("/home/someone"), Some(link.as_os_str()));
assert!(env.iter().all(|(n, _)| n != "SSH_AUTH_SOCK"));
// Named relative to here: not taken, even where it leads to one.
let here = std::env::current_dir().unwrap();
let up = here.components().count() - 1;
let relative: std::path::PathBuf = std::iter::repeat_n("..", up)
.collect::<std::path::PathBuf>()
.join(sock.strip_prefix("/").unwrap());
assert!(relative.exists() && relative.is_relative(), "{relative:?}");
assert_eq!(own_agent(Some(relative.as_os_str())), None);
// Another account's socket (the system's bus, owned by root, where
// there is one): not ours.
let system = Path::new("/run/dbus/system_bus_socket");
if system.exists() && own_uid() != 0 {
assert_eq!(own_agent(Some(system.as_os_str())), None);
}
let file = dir.path().join("plain");
std::fs::write(&file, "x").unwrap();
for bad in [
file.as_os_str(),
"agent.sock".as_ref(),
"/nonexistent/agent.sock".as_ref(),
"".as_ref(),
] {
assert_eq!(own_agent(Some(bad)), None, "{bad:?}");
}
assert_eq!(own_agent(None), None);
}
#[test]
fn this_executable_is_a_file_on_disk() {
let exe = this_executable().unwrap();