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:
1 parent
5ecc01b682
commit
562b893c64
3 files changed
+167
-37
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user