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:
1 parent
5bc0633cdd
commit
019d8d76a3
29 files changed
+6443
-136
No files matched your search
@@ -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());
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { .. } => (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user