fix: Windows reads of lock files, update paths cfg(unix), merge main again (P11c)
spec_vault swept every file under a book, lock files included; on Windows a held LockFileEx lock is mandatory and the read failed. The locks are already dedicated files (never data), so the test skips *.lock. Merged main (P13 step 6, the macOS server.log assert, P11a's AppImage identity): host.rs keeps main's five-field identity on Unix and size/last-write on Windows; the update poll and set_program_version are Unix-only. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
commit
7749afc557
234 files changed
+13028
-18411
No files matched your search
@@ -203,19 +203,39 @@ fn children_executable() -> PathBuf {
|
||||
}
|
||||
}
|
||||
|
||||
/// A program file's identity: what changes when the file is replaced.
|
||||
/// A program file's identity: device and inode, size and last write. A
|
||||
/// replaced file differs in the inode, and a rewritten one in the rest.
|
||||
#[cfg(unix)]
|
||||
type Identity = (u64, u64, u64, i64, i64);
|
||||
/// Windows: size and last write (nanoseconds).
|
||||
#[cfg(windows)]
|
||||
type Identity = (u64, u64);
|
||||
|
||||
#[cfg(unix)]
|
||||
fn identity_from(meta: &std::fs::Metadata) -> Identity {
|
||||
(
|
||||
meta.dev(),
|
||||
meta.ino(),
|
||||
meta.len(),
|
||||
meta.mtime(),
|
||||
meta.mtime_nsec(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The running executable's file identity, and its file's now: they
|
||||
/// differ once the file was replaced (INVARIANT 7).
|
||||
/// differ once the file was replaced (INVARIANT 7). An AppImage is its
|
||||
/// image file (`APPIMAGE`), not the program inside the mount.
|
||||
#[cfg(unix)]
|
||||
fn running_identity() -> Option<Identity> {
|
||||
if let Some(image) = crate::start::own_appimage(std::env::var_os("APPIMAGE").as_deref()) {
|
||||
return Some(identity_from(&std::fs::metadata(image).ok()?));
|
||||
}
|
||||
let running = if cfg!(target_os = "linux") {
|
||||
std::fs::metadata("/proc/self/exe").ok()?
|
||||
} else {
|
||||
std::fs::metadata(std::env::current_exe().ok()?).ok()?
|
||||
};
|
||||
Some((running.dev(), running.ino()))
|
||||
Some(identity_from(&running))
|
||||
}
|
||||
|
||||
/// Windows can't replace a program that runs, so an update puts the new
|
||||
@@ -232,12 +252,12 @@ fn file_identity() -> Option<Identity> {
|
||||
identity_of(&crate::start::this_executable().ok()?)
|
||||
}
|
||||
|
||||
/// The identity (device, inode) of the program at `path`: a file someone
|
||||
/// may run; else None.
|
||||
/// The identity of the program at `path`: a file someone may run; else
|
||||
/// None.
|
||||
#[cfg(unix)]
|
||||
fn identity_of(path: &Path) -> Option<Identity> {
|
||||
let meta = std::fs::metadata(path).ok()?;
|
||||
(meta.is_file() && meta.permissions().mode() & 0o111 != 0).then(|| (meta.dev(), meta.ino()))
|
||||
(meta.is_file() && meta.permissions().mode() & 0o111 != 0).then(|| identity_from(&meta))
|
||||
}
|
||||
|
||||
/// The identity (size, last write in nanoseconds) of the program at
|
||||
@@ -521,6 +541,13 @@ 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);
|
||||
#[cfg(unix)]
|
||||
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);
|
||||
@@ -678,6 +705,25 @@ impl Host {
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = update_poll.tick(), if matches!(self.phase, Phase::Open) => {
|
||||
// Staging an update replaces the program's file, which
|
||||
// Windows forbids while it runs: P11a step 5.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
//! 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).
|
||||
//! systemd's). One more: APPIMAGE, only when it names an absolute
|
||||
//! file, so the host knows its own AppImage (below).
|
||||
//! 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
|
||||
@@ -23,7 +24,11 @@
|
||||
//! scope couldn't be made (systemd-run failed without the host ever
|
||||
//! writing its first line), or there is no such scope, directly. A
|
||||
//! host that ran and stopped is never started a second time.
|
||||
//! 4. It runs this executable's file as it is on disk now.
|
||||
//! 4. It runs this executable's file as it is on disk now; for an
|
||||
//! AppImage that is the image file (APPIMAGE), not the program inside
|
||||
//! its mount, which goes away with the image it came from. The host
|
||||
//! reads that path about itself and never tells it to an agent (I-2),
|
||||
//! so a replaced image restarts the books like any replaced program.
|
||||
//! 5. The opener doesn't wait for it, and reaps it when it ends.
|
||||
//! ```
|
||||
|
||||
@@ -65,6 +70,9 @@ const LOG_LIMIT: u64 = 8 * 1024 * 1024;
|
||||
/// replaced file reads as "… (deleted)"; the path, not the old file, is
|
||||
/// what a new host runs.
|
||||
pub fn this_executable() -> Result<PathBuf, String> {
|
||||
if let Some(image) = own_appimage(std::env::var_os("APPIMAGE").as_deref()) {
|
||||
return Ok(PathBuf::from(image));
|
||||
}
|
||||
let exe = std::env::current_exe().map_err(|e| format!("can't find this program: {e}"))?;
|
||||
let text = exe.to_string_lossy();
|
||||
if let Some(path) = text.strip_suffix(" (deleted)")
|
||||
@@ -75,6 +83,13 @@ pub fn this_executable() -> Result<PathBuf, String> {
|
||||
Ok(exe)
|
||||
}
|
||||
|
||||
/// The AppImage this process runs from: `APPIMAGE` when it is an
|
||||
/// absolute path to a file.
|
||||
pub fn own_appimage(value: Option<&std::ffi::OsStr>) -> Option<std::ffi::OsString> {
|
||||
let path = Path::new(value?);
|
||||
(path.is_absolute() && path.is_file()).then(|| path.as_os_str().to_os_string())
|
||||
}
|
||||
|
||||
/// This account's user ID.
|
||||
#[cfg(unix)]
|
||||
fn own_uid() -> u32 {
|
||||
@@ -151,7 +166,11 @@ fn own_agent(named: Option<&std::ffi::OsStr>) -> Option<std::ffi::OsString> {
|
||||
#[cfg(unix)]
|
||||
/// 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)> {
|
||||
fn host_env(
|
||||
home: &Path,
|
||||
ssh_agent: Option<&std::ffi::OsStr>,
|
||||
appimage: 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()),
|
||||
@@ -165,6 +184,9 @@ fn host_env(home: &Path, ssh_agent: Option<&std::ffi::OsStr>) -> Vec<(String, st
|
||||
if let Some(agent) = own_agent(ssh_agent) {
|
||||
env.push(("SSH_AUTH_SOCK".into(), agent));
|
||||
}
|
||||
if let Some(image) = own_appimage(appimage) {
|
||||
env.push(("APPIMAGE".into(), image));
|
||||
}
|
||||
env
|
||||
}
|
||||
|
||||
@@ -209,6 +231,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<std::ffi::OsString> = vec!["host".into()];
|
||||
// An explicit root goes on as found (absolute): the host starts in /.
|
||||
@@ -253,6 +279,7 @@ pub fn start_host(
|
||||
.envs(host_env(
|
||||
&home,
|
||||
std::env::var_os("SSH_AUTH_SOCK").as_deref(),
|
||||
std::env::var_os("APPIMAGE").as_deref(),
|
||||
))
|
||||
.current_dir("/")
|
||||
.stdin(Stdio::null())
|
||||
@@ -518,7 +545,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn the_host_environment_carries_nothing_else() {
|
||||
let env = host_env(Path::new("/home/someone"), None);
|
||||
let env = host_env(Path::new("/home/someone"), None, None);
|
||||
let names: Vec<&str> = env.iter().map(|(n, _)| n.as_str()).collect();
|
||||
assert_eq!(&names[..2], ["PATH", "HOME"]);
|
||||
for n in &names[2..] {
|
||||
@@ -526,7 +553,8 @@ mod tests {
|
||||
[
|
||||
"XDG_RUNTIME_DIR",
|
||||
"DBUS_SESSION_BUS_ADDRESS",
|
||||
"SSH_AUTH_SOCK"
|
||||
"SSH_AUTH_SOCK",
|
||||
"APPIMAGE"
|
||||
]
|
||||
.contains(n),
|
||||
"{n}"
|
||||
@@ -536,6 +564,22 @@ mod tests {
|
||||
assert_eq!(env[1].1, "/home/someone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_an_absolute_file_passes_as_the_appimage() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let image = dir.path().join("W.AppImage");
|
||||
std::fs::write(&image, b"x").unwrap();
|
||||
let home = Path::new("/home/someone");
|
||||
let has = |v: Option<&std::ffi::OsStr>| {
|
||||
host_env(home, None, v).iter().any(|(n, _)| n == "APPIMAGE")
|
||||
};
|
||||
assert!(has(Some(image.as_os_str())));
|
||||
assert!(!has(None));
|
||||
assert!(!has(Some("W.AppImage".as_ref())), "relative");
|
||||
assert!(!has(Some(dir.path().as_os_str())), "a folder");
|
||||
assert!(!has(Some(dir.path().join("gone").as_os_str())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_an_own_ssh_agent_socket_passes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -546,7 +590,7 @@ mod tests {
|
||||
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 env = host_env(Path::new("/home/someone"), Some(sock.as_os_str()), None);
|
||||
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());
|
||||
@@ -555,7 +599,7 @@ mod tests {
|
||||
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()));
|
||||
let env = host_env(Path::new("/home/someone"), Some(link.as_os_str()), None);
|
||||
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();
|
||||
|
||||
@@ -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 `<root>/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 `<root>/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<Manifest, Failure> {
|
||||
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<Vec<u8>> {
|
||||
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<String> {
|
||||
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<Option<String>, 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<Option<String>, 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<Item = String>) -> Result<(), Failure> {
|
||||
@@ -337,7 +565,7 @@ pub fn rollback(prog: &str, args: impl IntoIterator<Item = String>) -> 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<std::fs::File, Failure> {
|
||||
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<std::fs::File, Failure> {
|
||||
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<std::fs::File, Failure> {
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
|
||||
Reference in new issue
Block a user