diff --git a/crates/ledger-cli/src/main.rs b/crates/ledger-cli/src/main.rs index cdb39b6..94028d7 100644 --- a/crates/ledger-cli/src/main.rs +++ b/crates/ledger-cli/src/main.rs @@ -331,7 +331,9 @@ fn run_local(mode: &str, args: Vec) -> i32 { "token" => ledger_host::commands::token(&prog, args), "connect" => ledger_host::commands::connect(&prog, args), "passphrase" => ledger_host::commands::passphrase(&prog, args), - "update" => ledger_host::update::update(&prog, env!("CARGO_PKG_VERSION"), args), + "update" => { + ledger_host::update::update(&prog, ledger_host::update::program_version(), args) + } "rollback" => ledger_host::update::rollback(&prog, args), _ => ledger_host::commands::books(&prog, args), }; @@ -399,6 +401,7 @@ fn is_bare_launch(words: &[String], stdin_is_terminal: bool) -> bool { } fn main() { + ledger_host::update::set_program_version(env!("CARGO_PKG_VERSION")); // A file's path may be any name the system takes: the file commands // get the words as given. Everything else is text. let given: Vec = env::args_os().skip(1).collect(); diff --git a/crates/ledger-cli/tests/update.rs b/crates/ledger-cli/tests/update.rs index 634f4dc..e6a2500 100644 --- a/crates/ledger-cli/tests/update.rs +++ b/crates/ledger-cli/tests/update.rs @@ -4,7 +4,8 @@ //! and host.rs; the P11a tests update_is_atomic, failed_update_rolls_back, //! unsigned_update_refused, bridge_survives_update, //! update_waits_for_a_running_call, package_managed_install_refuses_update, -//! and the rollback laws (kept version, the format gate). +//! and the rollback laws (kept version, the format gate); and step 4's +//! daily check, staged update and apply-at-next-start. #![cfg(target_os = "linux")] mod common; @@ -16,8 +17,7 @@ use std::process::{Command, Stdio}; use std::sync::mpsc; use std::time::{Duration, Instant}; -const KNOBS: &str = - "close_after=2500,backoff=50,exe_poll=100,init_wait=5000,queue_wait=30000,backup_quiet=300"; +const KNOBS: &str = "close_after=2500,backoff=50,exe_poll=100,init_wait=5000,queue_wait=30000,backup_quiet=300,update_check=400"; /// The platform key a manifest names, as the program computes it. fn platform() -> String { @@ -835,3 +835,161 @@ fn update_waits_for_a_running_call() { "a write lost, or made twice, across the update" ); } + +/// Name `release` as this home's local feed, as the owner would +/// (`/updates/feed`: the address, then the key file). +fn name_feed(home: &Home, release: &Release) { + std::fs::create_dir_all(home.updates()).unwrap(); + std::fs::write( + home.updates().join("feed"), + format!( + "file://{}\n{}\n", + release.dir.path().join("manifest.json").display(), + release.dir.path().join("release.pub").display() + ), + ) + .unwrap(); +} + +fn staged_version(home: &Home) -> Option { + let meta = std::fs::read(home.updates().join("staged/meta.json")).ok()?; + let v: Value = serde_json::from_slice(&meta).ok()?; + v["version"].as_str().map(str::to_string) +} + +#[test] +fn the_host_stages_a_daily_update_and_the_next_start_applies_it() { + let home = Home::new("daily"); + home.create("acme"); + let old = home.program(); + let release = Release::new(); + let next = newer(&home, "daily"); + release.publish("9.0.0", &next, |_| {}); + name_feed(&home, &release); + let mut agent = Agent::start(&home, "acme"); + agent.add_account("4000").unwrap(); + let host = wait_host(&home); + + // The host looks, downloads, verifies and stages; nothing running + // changes: not the file, not the host. + assert!( + wait_for(30, || staged_version(&home).as_deref() == Some("9.0.0")), + "nothing was staged" + ); + assert_eq!(home.program(), old, "the program was swapped while open"); + assert!(!home.updates().join("previous").exists()); + std::thread::sleep(Duration::from_millis(1200)); + assert_eq!(home.pids("host"), vec![host], "the host restarted"); + assert!(agent.add_account("4001").is_ok()); + + // The books close; the next opener applies it before the host starts. + drop(agent); + let out = home.cmd("close").output().unwrap(); + assert!(out.status.success(), "{}", text(&out)); + assert!(wait_for(20, || home.pids("host").is_empty())); + let mut agent = Agent::start(&home, "acme"); + assert_eq!(home.program(), next, "the staged update wasn't applied"); + assert!(!home.updates().join("staged").exists()); + assert!( + home.updates() + .join("previous") + .join("wanderbooks") + .is_file() + ); + assert_eq!( + std::fs::read(home.updates().join("previous/wanderbooks")).unwrap(), + old + ); + let host = wait_host(&home); + assert!( + !std::fs::read_link(format!("/proc/{host}/exe")) + .unwrap() + .to_string_lossy() + .contains("(deleted)") + ); + let chart = agent.call("books.chart", json!({})).unwrap(); + let codes: Vec<&str> = chart["accounts"] + .as_array() + .unwrap() + .iter() + .filter_map(|a| a["code"].as_str()) + .collect(); + assert!(codes.contains(&"4000") && codes.contains(&"4001")); +} + +#[test] +fn a_daily_update_that_is_not_ours_is_not_staged() { + let home = Home::new("daily-bad"); + home.create("acme"); + let release = Release::new(); + let next = newer(&home, "daily-bad"); + let other = minisign::KeyPair::generate_unencrypted_keypair().unwrap(); + // Signed by another key; and, separately, a wrong checksum. + let sig = Release::b64(&Release::sign_with(&other, &next)); + release.publish("9.0.0", &next, |e| e["signature"] = json!(sig)); + name_feed(&home, &release); + let _agent = Agent::start(&home, "acme"); + assert!( + wait_for(30, || home.updates().join("checked").exists()), + "no look" + ); + std::thread::sleep(Duration::from_millis(500)); + assert_eq!(staged_version(&home), None); + assert!(!home.updates().join("staged").exists()); + release.publish("9.0.1", &next, |e| e["sha256"] = json!("00".repeat(32))); + std::fs::remove_file(home.updates().join("checked")).unwrap(); + assert!( + wait_for(30, || home.updates().join("checked").exists()), + "no second look" + ); + std::thread::sleep(Duration::from_millis(500)); + assert_eq!(staged_version(&home), None); +} + +#[test] +fn update_installs_the_copy_the_host_staged_without_downloading_again() { + let home = Home::new("daily-use"); + home.create("acme"); + let release = Release::new(); + let next = newer(&home, "daily-use"); + release.publish("9.0.0", &next, |_| {}); + name_feed(&home, &release); + let _agent = Agent::start(&home, "acme"); + assert!(wait_for(30, || staged_version(&home).is_some())); + // The program file is gone from the feed: only the staged copy is left. + std::fs::remove_file(release.dir.path().join("wanderbooks-9.0.0")).unwrap(); + let out = home.run("update", None); + assert!(out.status.success(), "{}", text(&out)); + assert_eq!(home.program(), next); + assert!(!home.updates().join("staged").exists()); +} + +#[test] +fn a_staged_update_that_was_tampered_with_is_dropped_not_applied() { + let home = Home::new("daily-tamper"); + home.create("acme"); + let old = home.program(); + let release = Release::new(); + let next = newer(&home, "daily-tamper"); + release.publish("9.0.0", &next, |_| {}); + name_feed(&home, &release); + let agent = Agent::start(&home, "acme"); + assert!(wait_for(30, || staged_version(&home).is_some())); + drop(agent); + let out = home.cmd("close").output().unwrap(); + assert!(out.status.success(), "{}", text(&out)); + assert!(wait_for(20, || home.pids("host").is_empty())); + // The staged file changes, its recorded checksum with it: the + // signature no longer holds. + let staged = home.updates().join("staged"); + let mut bytes = std::fs::read(staged.join("wanderbooks")).unwrap(); + bytes.extend_from_slice(b"tamper"); + std::fs::write(staged.join("wanderbooks"), &bytes).unwrap(); + let mut meta: Value = + serde_json::from_slice(&std::fs::read(staged.join("meta.json")).unwrap()).unwrap(); + meta["sha256"] = json!(sha256(&bytes)); + std::fs::write(staged.join("meta.json"), meta.to_string()).unwrap(); + let _agent = Agent::start(&home, "acme"); + assert_eq!(home.program(), old, "a tampered update was applied"); + assert!(!staged.exists(), "and kept"); +} diff --git a/crates/ledger-host/src/host.rs b/crates/ledger-host/src/host.rs index fd6d0f0..bc99696 100644 --- a/crates/ledger-host/src/host.rs +++ b/crates/ledger-host/src/host.rs @@ -500,6 +500,12 @@ impl Host { let started_as = running_identity(); let mut exe_poll = tokio::time::interval(self.knobs.exe_poll); exe_poll.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + // The daily look for a newer release (P11a step 4): the work is + // a thread of its own, one at a time, and only stages a file. + let mut update_poll = + tokio::time::interval((self.knobs.update_check / 4).max(Duration::from_millis(10))); + update_poll.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + let looking = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let mut idle_at = Some(Instant::now() + self.knobs.close_after); let server_start = tokio::time::sleep(self.knobs.server_delay); tokio::pin!(server_start); @@ -657,6 +663,20 @@ impl Host { } } } + _ = update_poll.tick(), if matches!(self.phase, Phase::Open) => { + use std::sync::atomic::Ordering; + if !looking.swap(true, Ordering::SeqCst) { + let (root, every, looking) = (self.root.clone(), self.knobs.update_check, looking.clone()); + std::thread::spawn(move || { + match crate::update::stage_if_due(&root, crate::update::program_version(), every) { + Ok(Some(version)) => eprintln!("wanderbooks host: version {version} is downloaded and checked; it starts the next time the books open, or at `wanderbooks update`"), + Ok(None) => {} + Err(why) => eprintln!("wanderbooks host: the look for a newer version failed: {why}"), + } + looking.store(false, Ordering::SeqCst); + }); + } + } _ = exe_poll.tick(), if matches!(self.phase, Phase::Open) => { if started_as.is_some() && file_identity().is_some_and(|now| Some(now) != started_as) { eprintln!("wanderbooks host: a new version is installed: restarting"); diff --git a/crates/ledger-host/src/knobs.rs b/crates/ledger-host/src/knobs.rs index 04f24ad..bd7dd0c 100644 --- a/crates/ledger-host/src/knobs.rs +++ b/crates/ledger-host/src/knobs.rs @@ -33,6 +33,9 @@ pub struct Knobs { /// 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, + /// How long between the host's looks for a newer release (plan + /// P11a step 4: a day). + pub update_check: Duration, } impl Default for Knobs { @@ -47,6 +50,7 @@ impl Default for Knobs { open_wait: Duration::from_secs(120), backup_quiet: Duration::from_secs(30), drill_first: Duration::from_secs(60), + update_check: Duration::from_secs(24 * 60 * 60), } } } @@ -76,6 +80,7 @@ impl Knobs { "open_wait" => k.open_wait = d, "backup_quiet" => k.backup_quiet = d, "drill_first" => k.drill_first = d, + "update_check" => k.update_check = d, other => return Err(format!("{FLAG}: there is no knob {other:?}")), } } @@ -97,6 +102,7 @@ impl Knobs { ("open_wait", self.open_wait, d.open_wait), ("backup_quiet", self.backup_quiet, d.backup_quiet), ("drill_first", self.drill_first, d.drill_first), + ("update_check", self.update_check, d.update_check), ] { if now != default { parts.push(format!("{name}={}", now.as_millis())); @@ -130,6 +136,7 @@ mod tests { 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.update_check, Duration::from_secs(86_400)); assert_eq!(k.to_arg(), None); assert!(k.args().is_empty()); assert_eq!(Knobs::parse("").unwrap(), k); @@ -138,7 +145,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,backup_quiet=40,drill_first=50", + "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,update_check=60", ) .unwrap(); assert_eq!(k.close_after, Duration::from_millis(500)); @@ -150,6 +157,7 @@ mod tests { 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)); + assert_eq!(k.update_check, Duration::from_millis(60)); let args = k.args(); assert_eq!(args[0], FLAG); assert_eq!(Knobs::parse(&args[1]).unwrap(), k); diff --git a/crates/ledger-host/src/start.rs b/crates/ledger-host/src/start.rs index 19f348b..4409599 100644 --- a/crates/ledger-host/src/start.rs +++ b/crates/ledger-host/src/start.rs @@ -205,6 +205,10 @@ pub fn start_host( let found = ledger_store::root::Root::find(root).map_err(|e| e.to_string())?; let run = found.run_dir().map_err(|e| e.to_string())?; let home = ledger_store::root::account_home().map_err(|e| e.to_string())?; + // A staged update is applied here and nowhere while books are open: + // no host runs now, so no call is cut off (update.rs INVARIANT 9). A + // failure leaves the program as it was. + let _ = crate::update::apply_staged(&found, crate::update::program_version()); let exe = this_executable()?; let mut args: Vec = vec!["host".into()]; // An explicit root goes on as found (absolute): the host starts in /. diff --git a/crates/ledger-host/src/update.rs b/crates/ledger-host/src/update.rs index b1f2266..6d2bf37 100644 --- a/crates/ledger-host/src/update.rs +++ b/crates/ledger-host/src/update.rs @@ -36,7 +36,17 @@ //! newer one: the only way back is a restore, plan 3.9). //! 7. A package-managed install is never touched. //! 8. A program with no release key compiled in updates from nothing -//! but a local (`file://`) feed whose key the owner names. +//! but a local (`file://`) feed whose key the owner names: on the +//! command line, or in `/updates/feed` (the feed's address on +//! the first line, the key file's path on the second). +//! 9. The host looks for a newer release at most once a day while the +//! books are open, and only downloads and verifies it (the same +//! checks as INVARIANT 1) into `/updates/staged/`. It never +//! swaps: a staged update is applied only (a) by `wanderbooks +//! update`, the owner's say-so, or (b) by an opener just before it +//! starts a host, when no host is running, so no call is ever cut +//! off. A staged file is checked again against the key before it is +//! applied; one that fails, or does not start, is dropped. //! SCENARIOS: //! 1. IF the feed offers a higher version, signed by our key: SHOULD //! replace the program and keep the old one. @@ -44,6 +54,10 @@ //! is by another key or missing: SHOULD refuse, changing nothing. //! 3. IF the new program can't start: SHOULD put the old one back. //! 4. IF a book consented to a newer format: SHOULD refuse to roll back. +//! 5. IF the host's daily look finds a signed newer release: SHOULD +//! stage it and change nothing running; the next host start runs it. +//! 6. IF the daily look finds a bad signature or checksum: SHOULD stage +//! nothing. //! ``` use serde::Deserialize; @@ -72,6 +86,26 @@ const UPDATES: &str = "updates"; const PREVIOUS: &str = "previous"; const PROGRAM: &str = "wanderbooks"; const META: &str = "meta.json"; +const STAGED: &str = "staged"; +const FEED_FILE: &str = "feed"; +const CHECKED: &str = "checked"; + +/// The version of the program being run, set by its `main` (this crate's +/// own is the fallback: the packages rewrite only the program's). +static PROGRAM_VERSION: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new(); + +/// Say which version this program is. +pub fn set_program_version(version: &'static str) { + let _ = PROGRAM_VERSION.set(version); +} + +/// This program's version. +pub fn program_version() -> &'static str { + PROGRAM_VERSION + .get() + .copied() + .unwrap_or(env!("CARGO_PKG_VERSION")) +} const MAX_MANIFEST: usize = 1 << 20; const MAX_PROGRAM: u64 = 1 << 30; @@ -244,11 +278,18 @@ pub fn update( Install::SelfUpdating(target) => target, }; let current_v = parse_version(current).ok_or("this program's version isn't one of ours")?; - let feed = flags.feed.as_deref().unwrap_or(DEFAULT_FEED); - let key = trusted_key(feed, flags.key_file.as_deref())?; - let manifest_bytes = fetch(feed, MAX_MANIFEST as u64)?; - let manifest: Manifest = serde_json::from_slice(&manifest_bytes) - .map_err(|e| format!("the release manifest isn't one we can read: {e}"))?; + let found_root = ledger_store::root::Root::find(flags.root.as_deref()).ok(); + let local = found_root + .as_ref() + .and_then(|r| local_feed(&r.path().join(UPDATES))); + let (feed, key_file) = match (&flags.feed, &local) { + (Some(feed), _) => (feed.clone(), flags.key_file.clone()), + (None, Some((feed, key))) => (feed.clone(), Some(key.clone())), + (None, None) => (DEFAULT_FEED.to_string(), flags.key_file.clone()), + }; + let feed = feed.as_str(); + let key = trusted_key(feed, key_file.as_deref())?; + let manifest = fetch_manifest(feed)?; let offered = parse_version(&manifest.version).ok_or_else(|| { format!( "the manifest's version {:?} isn't one of ours", @@ -272,9 +313,14 @@ pub fn update( .get(&platform) .ok_or_else(|| format!("release {} has no build for {platform}", manifest.version))?; if flags.check { + let ready = found_root + .as_ref() + .and_then(|r| staged_bytes(&r.path().join(UPDATES), &manifest.version)) + .is_some(); println!( - "wanderbooks {} is available (this is {current}). Run `{}` to install it.", + "wanderbooks {} is available{} (this is {current}). Run `{}` to install it.", manifest.version, + if ready { ", already downloaded" } else { "" }, prog.trim_end_matches(" --check") ); if !manifest.notes.trim().is_empty() { @@ -284,9 +330,14 @@ pub fn update( } let root = ledger_store::root::Root::find(flags.root.as_deref()).map_err(|e| e.to_string())?; let updates = updates_dir(&root)?; - let _lock = take_lock(&updates)?; + let _lock = take_lock_within(&updates, Duration::from_secs(10))?; - let bytes = fetch(&resolve(feed, &entry.url)?, MAX_PROGRAM)?; + // A copy the host already downloaded and verified is used as it is + // found (and verified again below); else the download. + let bytes = match staged_bytes(&updates, &manifest.version) { + Some(bytes) => bytes, + None => fetch(&resolve(feed, &entry.url)?, MAX_PROGRAM)?, + }; verify(&bytes, entry, &key)?; // Verified: from here the files are ours to write. Staged, then the @@ -313,6 +364,7 @@ pub fn update( .into()); } let _ = std::fs::remove_dir_all(&staged_dir); + let _ = std::fs::remove_dir_all(updates.join(STAGED)); println!( "Updated wanderbooks from {current} to {}.", manifest.version @@ -323,6 +375,182 @@ pub fn update( Ok(()) } +/// The manifest at `feed`. +fn fetch_manifest(feed: &str) -> Result { + let bytes = fetch(feed, MAX_MANIFEST as u64)?; + serde_json::from_slice(&bytes) + .map_err(|e| format!("the release manifest isn't one we can read: {e}").into()) +} + +/// The feed the owner named for this root (INVARIANT 8): a `file://` +/// address, and the file of the key to check it against. +fn local_feed(updates: &Path) -> Option<(String, PathBuf)> { + let text = std::fs::read_to_string(updates.join(FEED_FILE)).ok()?; + let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty()); + let feed = lines.next()?; + let key = lines.next()?; + feed.starts_with("file://") + .then(|| (feed.to_string(), PathBuf::from(key))) +} + +/// The staged program, if it is the one for `version` and whole. +fn staged_bytes(updates: &Path, version: &str) -> Option> { + let dir = updates.join(STAGED); + let meta: serde_json::Value = + serde_json::from_slice(&std::fs::read(dir.join(META)).ok()?).ok()?; + if meta["version"].as_str() != Some(version) { + return None; + } + let bytes = std::fs::read(dir.join(PROGRAM)).ok()?; + sha256_hex(&bytes) + .eq_ignore_ascii_case(meta["sha256"].as_str()?) + .then_some(bytes) +} + +/// The version staged, if any. +pub fn staged_version(root: &ledger_store::root::Root) -> Option { + let meta = std::fs::read(root.path().join(UPDATES).join(STAGED).join(META)).ok()?; + let meta: serde_json::Value = serde_json::from_slice(&meta).ok()?; + meta["version"].as_str().map(str::to_string) +} + +/// INVARIANT 9, the host's daily look: stage a newer release if there is +/// one and `every` has passed since the last look. Returns the version +/// newly staged. Changes nothing that runs. +pub fn stage_if_due( + root: &ledger_store::root::Root, + current: &str, + every: Duration, +) -> Result, Failure> { + let Install::SelfUpdating(_) = this_install()? else { + return Ok(None); + }; + let updates = updates_dir(root)?; + let checked = updates.join(CHECKED); + if std::fs::metadata(&checked) + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.elapsed().ok()) + .is_some_and(|age| age < every) + { + return Ok(None); + } + // Marked first: a failed look waits a day too. + std::fs::write(&checked, b"").map_err(|e| format!("can't note the look: {}", e.kind()))?; + let current_v = parse_version(current).ok_or("this program's version isn't one of ours")?; + let (feed, key_file) = match local_feed(&updates) { + Some((feed, key)) => (feed, Some(key)), + None => (DEFAULT_FEED.to_string(), None), + }; + let key = trusted_key(&feed, key_file.as_deref())?; + let Ok(_lock) = take_lock(&updates) else { + return Ok(None); + }; + let manifest = fetch_manifest(&feed)?; + let offered = + parse_version(&manifest.version).ok_or("the manifest's version isn't one of ours")?; + if offered <= current_v { + let _ = std::fs::remove_dir_all(updates.join(STAGED)); + return Ok(None); + } + if staged_bytes(&updates, &manifest.version).is_some() { + return Ok(None); + } + let platform = format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH); + let entry = manifest + .platforms + .get(&platform) + .ok_or_else(|| format!("release {} has no build for {platform}", manifest.version))?; + let bytes = fetch(&resolve(&feed, &entry.url)?, MAX_PROGRAM)?; + verify(&bytes, entry, &key)?; + let dir = updates.join(STAGED); + let _ = std::fs::remove_dir_all(&dir); + private_dir(&dir)?; + write_program(&dir.join(PROGRAM), &bytes)?; + let meta = serde_json::json!({ + "version": manifest.version, + "sha256": sha256_hex(&bytes), + "signature": entry.signature, + "notes": manifest.notes, + }); + std::fs::write(dir.join(META), meta.to_string()) + .map_err(|e| format!("can't stage the update: {}", e.kind()))?; + Ok(Some(manifest.version)) +} + +/// INVARIANT 9, applying: swap in the staged program, if there is one +/// for a newer version. The caller guarantees no host is running (it is +/// about to start one) or is the owner running `update`. Returns the +/// version applied. +pub fn apply_staged( + root: &ledger_store::root::Root, + current: &str, +) -> Result, Failure> { + let updates = root.path().join(UPDATES); + let dir = updates.join(STAGED); + if !dir.join(META).is_file() { + return Ok(None); + } + let Install::SelfUpdating(target) = this_install()? else { + return Ok(None); + }; + let Ok(_lock) = take_lock(&updates) else { + return Ok(None); + }; + let meta: serde_json::Value = std::fs::read(dir.join(META)) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()) + .unwrap_or_default(); + let drop_staged = || { + let _ = std::fs::remove_dir_all(&dir); + }; + let (Some(version), Some(sha256), Some(signature)) = ( + meta["version"].as_str(), + meta["sha256"].as_str(), + meta["signature"].as_str(), + ) else { + drop_staged(); + return Ok(None); + }; + let current_v = parse_version(current).ok_or("this program's version isn't one of ours")?; + if parse_version(version).is_none_or(|v| v <= current_v) { + drop_staged(); + return Ok(None); + } + let local = local_feed(&updates); + let key = trusted_key( + local.as_ref().map_or(DEFAULT_FEED, |(f, _)| f.as_str()), + local.as_ref().map(|(_, k)| k.as_path()), + )?; + let Some(bytes) = staged_bytes(&updates, version) else { + drop_staged(); + return Err("the staged update is damaged: dropped".into()); + }; + let entry = Entry { + url: String::new(), + sha256: sha256.to_string(), + signature: signature.to_string(), + }; + if let Err(e) = verify(&bytes, &entry, &key) { + drop_staged(); + return Err(e); + } + drop(bytes); + keep_previous(&updates, &target, current)?; + swap_in(&target, &dir.join(PROGRAM))?; + if let Err(why) = healthy(&target) { + let back = swap_in(&target, &updates.join(PREVIOUS).join(PROGRAM)); + drop_staged(); + return Err(match back { + Ok(()) => format!("the staged version {version} didn't start ({why}); the old one is back"), + Err(e) => format!("the staged version {version} didn't start ({why}) and the old one couldn't be put back ({e})"), + } + .into()); + } + drop_staged(); + Ok(Some(version.to_string())) +} + /// `wanderbooks rollback`: go back to the version the last update /// replaced. pub fn rollback(prog: &str, args: impl IntoIterator) -> Result<(), Failure> { @@ -337,7 +565,7 @@ pub fn rollback(prog: &str, args: impl IntoIterator) -> Result<() }; let root = ledger_store::root::Root::find(flags.root.as_deref()).map_err(|e| e.to_string())?; let updates = updates_dir(&root)?; - let _lock = take_lock(&updates)?; + let _lock = take_lock_within(&updates, Duration::from_secs(10))?; let previous = updates.join(PREVIOUS); let meta: serde_json::Value = std::fs::read(previous.join(META)) .ok() @@ -562,6 +790,24 @@ fn private_dir(dir: &Path) -> Result<(), Failure> { /// One update or rollback at a time. fn take_lock(updates: &Path) -> Result { + take_lock_within(updates, Duration::ZERO) +} + +/// The same, waiting up to `wait` for another to finish (the host's +/// look at the feed is a short holder). +fn take_lock_within(updates: &Path, wait: Duration) -> Result { + let deadline = Instant::now() + wait; + loop { + match take_lock_once(updates) { + Err(e) if Instant::now() < deadline && e.to_string().contains("running") => { + std::thread::sleep(Duration::from_millis(50)); + } + other => return other, + } + } +} + +fn take_lock_once(updates: &Path) -> Result { let file = std::fs::OpenOptions::new() .create(true) .append(true)