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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
swaitsandClaude Fable 5.1 committed 2026-10-29 16:15:11 -06:00
1 parent 5ecc01b682
commit 562b893c64
3 files changed
+167 -37

No files matched your search

+94 -22
View File
@@ -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<PathBuf>,
}
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<u8> {
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<String>) {
let home = Home::new(tag);
update_under_writes_in(Home::new(tag))
}
fn update_under_writes_in(home: Home) -> (Home, Agent, Vec<String>) {
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<String>) {
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
+26 -8
View File
@@ -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<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))
}
/// 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> {
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<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))
}
/// Wait at most `wait` for a host to answer on the lease socket: whether
+47 -7
View File
@@ -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<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)")
@@ -59,6 +67,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.
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<std::ffi::OsString> {
/// 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();