From 562b893c643731aad9428f05b86be275997c0976 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 29 Oct 2026 16:15:11 -0600 Subject: [PATCH] feat(host): an AppImage's host learns its image and restarts onto a replaced one (P11a step 3 gap) An AppImage runs the program from a mount that goes away with the image it came from, so a host held open past an update kept running the old mount and never noticed the replaced image. The host now reads APPIMAGE (added to its environment allow-list only when it names an absolute file; it is a path the host reads about itself and never tells an agent, so I-2 holds), runs and restarts from that file, and tells a replaced program by its file's inode, size and last write. Test: a_replaced_appimage_restarts_the_books (the programs run from one file while APPIMAGE names another, as in a mount). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp --- crates/ledger-cli/tests/update.rs | 116 ++++++++++++++++++++++++------ crates/ledger-host/src/host.rs | 34 ++++++--- crates/ledger-host/src/start.rs | 54 ++++++++++++-- 3 files changed, 167 insertions(+), 37 deletions(-) diff --git a/crates/ledger-cli/tests/update.rs b/crates/ledger-cli/tests/update.rs index 4f121b2..634f4dc 100644 --- a/crates/ledger-cli/tests/update.rs +++ b/crates/ledger-cli/tests/update.rs @@ -76,6 +76,10 @@ fn base_program() -> &'static Path { struct Home { dir: tempfile::TempDir, exe: PathBuf, + /// A pretend AppImage (P11a step 4): the programs run from `exe`, as + /// from an image's mount, and `APPIMAGE` names this other file, the + /// one an update replaces. + image: Option, } impl Home { @@ -95,7 +99,22 @@ impl Home { std::fs::create_dir(&bin).unwrap(); let exe = bin.join("wanderbooks"); std::fs::copy(base_program(), &exe).unwrap(); - let home = Self { dir, exe }; + let home = Self { + dir, + exe, + image: None, + }; + home.runnable(); + home + } + + /// A home whose program is an AppImage: the same program twice, one + /// copy to run (the mount's) and one the image file `APPIMAGE` names. + fn new_image(tag: &str) -> Self { + let mut home = Self::new(tag); + let image = home.dir.path().join("Wanderbooks.AppImage"); + std::fs::copy(base_program(), &image).unwrap(); + home.image = Some(image); home.runnable(); home } @@ -103,25 +122,35 @@ impl Home { /// Wait until the copy can be run (a child another test thread forked /// while it was written may hold it open for a moment). fn runnable(&self) { - let deadline = Instant::now() + Duration::from_secs(10); - loop { - match Command::new(&self.exe) - .arg("--help") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - { - Err(e) if e.kind() == std::io::ErrorKind::ExecutableFileBusy => { - assert!(Instant::now() < deadline, "the copy stayed busy"); - std::thread::sleep(Duration::from_millis(20)); + for exe in std::iter::once(&self.exe).chain(self.image.as_ref()) { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + match Command::new(exe) + .arg("--help") + .env("APPIMAGE_EXTRACT_AND_RUN", "1") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + { + Err(e) if e.kind() == std::io::ErrorKind::ExecutableFileBusy => { + assert!(Instant::now() < deadline, "the copy stayed busy"); + std::thread::sleep(Duration::from_millis(20)); + } + Err(e) => panic!("the copy can't be run: {e}"), + Ok(_) => break, } - Err(e) => panic!("the copy can't be run: {e}"), - Ok(_) => return, } } } + /// The environment of the pretend AppImage, if this is one. + fn image_env(&self, c: &mut Command) { + if let Some(image) = &self.image { + c.env("APPIMAGE", image); + } + } + fn root(&self) -> PathBuf { self.dir.path().join(".local/share/wanderbooks") } @@ -139,11 +168,13 @@ impl Home { .env_clear() .env("PATH", "/usr/bin:/bin") .env("HOME", self.dir.path()); + self.image_env(&mut c); c } + /// The program that updates replace: the image, if there is one. fn program(&self) -> Vec { - std::fs::read(&self.exe).unwrap() + std::fs::read(self.image.as_ref().unwrap_or(&self.exe)).unwrap() } fn create(&self, id: &str) { @@ -180,6 +211,7 @@ impl Home { .env("PATH", "/usr/bin:/bin") .env("HOME", self.dir.path()) .stdin(Stdio::null()); + self.image_env(&mut c); c.output().unwrap() } @@ -655,7 +687,11 @@ impl Drop for Agent { /// host and server run the new file afterwards, and the head is the one /// the writes made. fn update_under_writes(tag: &str) -> (Home, Agent, Vec) { - let home = Home::new(tag); + update_under_writes_in(Home::new(tag)) +} + +fn update_under_writes_in(home: Home) -> (Home, Agent, Vec) { + let tag = "under-writes"; home.create("acme"); let release = Release::new(); let next = newer(&home, tag); @@ -673,18 +709,21 @@ fn update_under_writes(tag: &str) -> (Home, Agent, Vec) { release.dir.path().join("manifest.json").display() ); let key = release.dir.path().join("release.pub"); + let image = home.image.clone(); std::thread::spawn(move || { let _keep = &release; - Command::new(exe) - .args(["update", "--root"]) + let mut c = Command::new(exe); + c.args(["update", "--root"]) .arg(root) .args(["--feed", &feed, "--trusted-key"]) .arg(key) .env_clear() .env("PATH", "/usr/bin:/bin") - .env("HOME", dir) - .output() - .unwrap() + .env("HOME", dir); + if let Some(image) = image { + c.env("APPIMAGE", image); + } + c.output().unwrap() }) }; let mut written = vec!["4000".to_string()]; @@ -742,6 +781,39 @@ fn bridge_survives_update() { assert!(in_books >= written.len(), "{in_books} < {}", written.len()); } +#[test] +fn a_replaced_appimage_restarts_the_books() { + // The programs run from one file (an image's mount) while `APPIMAGE` + // names another (the image). Replacing the image alone must restart + // the host onto it, with the session and every write intact. + let home = Home::new_image("appimage"); + let image = home.image.clone().unwrap(); + home.create("acme"); + let release = Release::new(); + let next = newer(&home, "appimage"); + release.publish("9.0.0", &next, |_| {}); + let mut agent = Agent::start(&home, "acme"); + agent.add_account("4000").unwrap(); + let host_before = wait_host(&home); + let out = home.run("update", Some(&release)); + assert!(out.status.success(), "{}", text(&out)); + assert_eq!(std::fs::read(&image).unwrap(), next); + // The mount's copy is untouched; only the image changed. + assert_ne!(std::fs::read(&home.exe).unwrap(), next); + assert!( + wait_for(60, || { + home.pids("host").len() == 1 && home.pids("host")[0] != host_before + }), + "the host never restarted onto the replaced image" + ); + let host = wait_host(&home); + assert_eq!( + std::fs::read_link(format!("/proc/{host}/exe")).unwrap(), + image + ); + assert!(agent.add_account("4001").is_ok(), "the session carries on"); +} + #[test] fn update_waits_for_a_running_call() { // No call is cut off or made twice: each account written across the diff --git a/crates/ledger-host/src/host.rs b/crates/ledger-host/src/host.rs index 7921311..fd6d0f0 100644 --- a/crates/ledger-host/src/host.rs +++ b/crates/ledger-host/src/host.rs @@ -196,28 +196,46 @@ fn children_executable() -> PathBuf { } } +/// 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. +type Identity = (u64, u64, u64, i64, i64); + +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). -fn running_identity() -> Option<(u64, u64)> { +/// differ once the file was replaced (INVARIANT 7). An AppImage is its +/// image file (`APPIMAGE`), not the program inside the mount. +fn running_identity() -> Option { + 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)) } /// The identity of the file at this executable's path, if it is a /// program (a file half-written or gone doesn't count yet). -fn file_identity() -> Option<(u64, u64)> { +fn file_identity() -> Option { identity_of(&crate::start::this_executable().ok()?) } -/// The identity (device, inode) of the program at `path`: a file someone -/// may run; else None. -fn identity_of(path: &Path) -> Option<(u64, u64)> { +/// The identity of the program at `path`: a file someone may run; else +/// None. +fn identity_of(path: &Path) -> Option { 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)) } /// Wait at most `wait` for a host to answer on the lease socket: whether diff --git a/crates/ledger-host/src/start.rs b/crates/ledger-host/src/start.rs index 47f7bae..19f348b 100644 --- a/crates/ledger-host/src/start.rs +++ b/crates/ledger-host/src/start.rs @@ -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. //! ``` @@ -49,6 +54,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 { + 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)") @@ -59,6 +67,13 @@ pub fn this_executable() -> Result { 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 { + let path = Path::new(value?); + (path.is_absolute() && path.is_file()).then(|| path.as_os_str().to_os_string()) +} + /// This account's user ID. fn own_uid() -> u32 { // SAFETY: geteuid has no preconditions and can't fail. @@ -128,7 +143,11 @@ fn own_agent(named: Option<&std::ffi::OsStr>) -> Option { /// 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()), @@ -142,6 +161,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 } @@ -227,6 +249,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()) @@ -385,7 +408,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..] { @@ -393,7 +416,8 @@ mod tests { [ "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS", - "SSH_AUTH_SOCK" + "SSH_AUTH_SOCK", + "APPIMAGE" ] .contains(n), "{n}" @@ -403,6 +427,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(); @@ -413,7 +453,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()); @@ -422,7 +462,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();