From eb100edfd6ac480fef11a13f4c5fdc18addd545b Mon Sep 17 00:00:00 2001 From: StrajnarFilip <46705237+StrajnarFilip@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:09:52 +0100 Subject: [PATCH] Linux: ignore an AppImage environment inherited from another program (#5653) (#5690) * Linux: ignore an AppImage environment inherited from another program (#5653) An AppImage app that opens a document through xdg-open passes its APPIMAGE, APPDIR, ARGV0 and OWD on to the WordCraft it starts. The self-update trusts APPIMAGE, so an installed WordCraft replaced the other app's file with its own AppImage. The app now starts over without those variables, first thing in main, unless its executable is under APPDIR. The update then goes through the package or plain file this copy really is, and a real AppImage keeps its variables. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LvG6E2QwHfTULQtWYgVEXk * #5653 review: turn the self-update off when starting over fails If the process can't start again without another program's AppImage variables, it still carries APPIMAGE, and an accepted update would replace that program's file. The app now turns its self-update off for that process: no update is offered, and Help > Check for Updates says why. The app still opens, so the document the user asked for isn't lost. Also documents the test helpers. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LvG6E2QwHfTULQtWYgVEXk --------- Co-authored-by: Filip Strajnar Co-authored-by: Claude Opus 5.5 Co-authored-by: Martin Ball --- ROADMAP.md | 3 + apps/wordcraft/src/appimage_env.rs | 139 ++++++++++++++++++++++++ apps/wordcraft/src/main.rs | 19 ++++ apps/wordcraft/tests/it/appimage_env.rs | 99 +++++++++++++++++ apps/wordcraft/tests/it/main.rs | 2 + crates/ui-egui/src/updates_ui.rs | 45 ++++++++ 6 files changed, 307 insertions(+) create mode 100644 apps/wordcraft/src/appimage_env.rs create mode 100644 apps/wordcraft/tests/it/appimage_env.rs diff --git a/ROADMAP.md b/ROADMAP.md index cc76e2d375..6549c2d8bf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -164,6 +164,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-10-30: Equations: `\int`, `\sum` and the other n-ary operators typed with AutoCorrect wait for their limits. `\int` + Space, then `_0^1` and Space, gives ∫ with its limits and the caret in its operand, and `\int_0^1` typed in one go does the same, since any character that can't continue `\name` corrects it; this is observed in Word for Windows on #4938. Enter or an arrow key builds it without going into the operand, and `∫∫` puts the operand in the inner operator; those two are not observed in Word (#4917). Readiness unchanged. - 2026-11-04: RTF import skips the declared Unicode fallback count even when it exceeds eight fallback units, so longer ANSI alternatives do not leak into the document text. Skipping stays bounded by the available input and ends at a group boundary. Readiness estimates unchanged. +- 2026-11-05: Linux: a WordCraft started by an AppImage app no longer takes that app's file for its own (#5653). The AppImage runtime's `APPIMAGE`, `APPDIR`, `ARGV0` and `OWD` pass to whatever the app starts, so an installed WordCraft opened through `xdg-open` saw another program's `APPIMAGE`, and its self-update replaced that program's file. The app now starts over without those variables unless its executable is under `APPDIR`, so the update goes through the package or plain file it really is. Should starting over fail, the self-update is off in that window and Help › Check for Updates says why. Readiness estimates unchanged. - 2026-10-28: Ink replay, eraser picking, lasso selection and arrow-candidate discovery reuse the paragraph prefixes they have already scanned (#3614). Page item order, stroke geometry and saved data retain their behavior. This narrows the stability work; ink import fidelity, native pen input and broader corpus qualification remain open. - 2026-10-29: Page drawing finds text-box warps without rescanning the paragraph for every box (#3614). Only requested offsets are indexed, and each paragraph walk stops after its last requested character. Original Linux discovery, complete display-output and CLI/MCP save/reopen controls; native Word comparison untested. Readiness estimates unchanged. - 2026-10-28: Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged. Existing mutation and protection checks retain their costs; this does not close the broader performance gap. @@ -814,6 +815,8 @@ MATH glyphs that reference the same construction now share its immutable variant ## Revision history +> 2026-11-05 · Linux self-update ignores another program's AppImage variables (#5653); recorded in the progress log, readiness unchanged. +> > 2026-11-03 · Arabic dialogs read right to left (rtl::Table); recorded in the progress log, readiness unchanged. > > 2026-10-30 · Technical synchronization: retain the Proofing pane and command/selection/undo contracts on main `3b7f7ed`; new-SHA validation pending, readiness unchanged. diff --git a/apps/wordcraft/src/appimage_env.rs b/apps/wordcraft/src/appimage_env.rs new file mode 100644 index 0000000000..9772244621 --- /dev/null +++ b/apps/wordcraft/src/appimage_env.rs @@ -0,0 +1,139 @@ +//! An AppImage's environment inherited from another program (#5653). +//! +//! The AppImage runtime gives the program it starts `APPIMAGE` (the image's file), `APPDIR` (where +//! it is mounted), `ARGV0` and `OWD`, and whatever that program starts inherits them. So an AppImage +//! app that opens a document through `xdg-open` starts an installed WordCraft with *its* `APPIMAGE`, +//! and the self-update, which trusts that variable, replaces the other app's file with WordCraft. +//! +//! This copy is in the image only when its executable is under `APPDIR`. Otherwise the variables +//! are another program's, and the process starts over without them, before anything reads them. +//! (Removing them in place is `unsafe` under this edition, and `exec` keeps the process id, the +//! arguments and the standard streams.) + +use std::ffi::OsStr; +use std::io::Write; +use std::os::unix::process::CommandExt; +use std::path::Path; +use std::process::Command; + +/// What the AppImage runtime sets; the same list as the documented workaround on the issue. +const RUNTIME_VARS: [&str; 4] = ["APPIMAGE", "APPDIR", "ARGV0", "OWD"]; + +/// Whether the executable runs from the image mounted at `appdir`. Anything unclear counts as not: +/// the update then goes through the package or the plain file this copy really is, and at worst +/// says it can't, where the other answer would overwrite a file that isn't WordCraft's. +fn runs_from_image(exe: Option<&Path>, appdir: Option<&OsStr>) -> bool { + let (Some(exe), Some(appdir)) = (exe, appdir) else { return false }; + let appdir = Path::new(appdir); + if !appdir.is_absolute() { + return false; + } + let Ok(appdir) = appdir.canonicalize() else { return false }; + // No image mounts at the root, where every program on the machine would count as inside it. + appdir.parent().is_some() && exe.starts_with(&appdir) +} + +/// Starts this process over without another program's AppImage variables; returns only when there +/// are none to drop (`None`) or starting over failed. Call it first, before any other thread starts. +pub(crate) fn drop_inherited() -> Option { + let appimage = std::env::var_os("APPIMAGE")?; + let exe = std::env::current_exe().ok(); + if runs_from_image(exe.as_deref(), std::env::var_os("APPDIR").as_deref()) { + return None; + } + // The logger isn't up yet, and wouldn't survive `exec`. Unlike `eprintln!`, never panics. + let _ = writeln!(std::io::stderr(), "wordcraft: ignoring the AppImage {:?} inherited from another program", Path::new(&appimage)); + let mut args = std::env::args_os(); + // This file, whatever has happened to its path since it started. + let mut again = Command::new("/proc/self/exe"); + if let Some(arg0) = args.next() { + again.arg0(arg0); + } + again.args(args); + for var in RUNTIME_VARS { + again.env_remove(var); + } + Some(again.exec()) +} + +#[cfg(test)] +mod tests { + use super::runs_from_image; + use std::path::{Path, PathBuf}; + + /// A folder of its own for one test, removed when it ends. + struct Tmp(PathBuf); + + impl Tmp { + /// An empty folder named after `test` and this process, made afresh. + fn new(test: &str) -> Tmp { + let dir = std::env::temp_dir().join(format!("wordcraft-appimage-{test}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + Tmp(dir) + } + } + + impl Drop for Tmp { + /// Removes the folder and what the test left in it. + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + /// A mount with the executable inside it, by real path (`/tmp` may be a link). + fn mount(root: &Path, name: &str) -> (PathBuf, PathBuf) { + let dir = root.join(name); + std::fs::create_dir_all(dir.join("usr/bin")).unwrap(); + let dir = dir.canonicalize().unwrap(); + let exe = dir.join("usr/bin/wordcraft"); + (dir, exe) + } + + #[test] + fn an_executable_in_the_mount_is_the_image() { + let tmp = Tmp::new("own"); + let (dir, exe) = mount(&tmp.0, ".mount_WordCr"); + assert!(runs_from_image(Some(&exe), Some(dir.as_os_str()))); + } + + #[test] + fn a_link_to_the_mount_is_the_same_mount() { + let tmp = Tmp::new("link"); + let (dir, exe) = mount(&tmp.0, ".mount_WordCr"); + let link = tmp.0.join("link"); + std::os::unix::fs::symlink(&dir, &link).unwrap(); + assert!(runs_from_image(Some(&exe), Some(link.as_os_str()))); + } + + #[test] + fn an_installed_executable_is_not_in_another_programs_mount() { + let tmp = Tmp::new("other"); + let (other, _) = mount(&tmp.0, ".mount_other"); + assert!(!runs_from_image(Some(Path::new("/usr/bin/wordcraft")), Some(other.as_os_str()))); + } + + /// Paths compare by component: one mount's name can start with another's. + #[test] + fn a_mount_whose_name_starts_the_same_is_another_mount() { + let tmp = Tmp::new("prefix"); + let (other, _) = mount(&tmp.0, ".mount_ab"); + let (_, exe) = mount(&tmp.0, ".mount_abc"); + assert!(!runs_from_image(Some(&exe), Some(other.as_os_str()))); + } + + #[test] + fn anything_unclear_is_not_the_image() { + let tmp = Tmp::new("unclear"); + let (dir, exe) = mount(&tmp.0, ".mount_WordCr"); + // No `APPDIR`, one that's gone (the other program has exited) or empty. + assert!(!runs_from_image(Some(&exe), None)); + assert!(!runs_from_image(Some(&exe), Some(tmp.0.join("gone").as_os_str()))); + assert!(!runs_from_image(Some(&exe), Some("".as_ref()))); + // The root, and a relative path that happens to lead here. + assert!(!runs_from_image(Some(&exe), Some("/".as_ref()))); + assert!(!runs_from_image(Some(&exe), Some(".".as_ref()))); + // No executable path at all. + assert!(!runs_from_image(None, Some(dir.as_os_str()))); + } +} diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index 2c9eb1d679..30cc1082c7 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -10,6 +10,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod animations; +#[cfg(target_os = "linux")] +mod appimage_env; #[cfg(target_os = "macos")] mod apple_events; mod clipboard; @@ -449,9 +451,22 @@ fn main() -> eframe::Result { if let Some(result) = craft_update::self_update::finish(&wordcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::>()) { std::process::exit(i32::from(result.is_err())); } + // Linux: before anything reads another program's AppImage variables, or a thread starts (#5653). + #[cfg(target_os = "linux")] + let start_over_failed = appimage_env::drop_inherited(); + // Still carrying them, this process must never update itself: the update would replace the + // other program's file. + #[cfg(target_os = "linux")] + let updates_unsafe = start_over_failed.is_some(); + #[cfg(not(target_os = "linux"))] + let updates_unsafe = false; // First, so the panic hook and every start-up warning are recorded (`logging`). let logger = logging::install(); logging::install_panic_hook(); + #[cfg(target_os = "linux")] + if let Some(e) = start_over_failed { + log::error!("couldn't start again without another program's AppImage variables, so the self-update is off in this window: {e}"); + } let mut control_port: Option = std::env::var("WORDCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()); let mut files = Vec::new(); let mut sample = false; @@ -566,6 +581,10 @@ fn main() -> eframe::Result { let mut app = WordApp::new(session, services(file_dialog, &cc.egui_ctx)); app.set_startup_update_check(true); load_prefs(&mut app); + // After the preferences, so the reason it gives is in the interface language. + if updates_unsafe { + app.disable_self_update(); + } load_user_dictionary(&mut app); // Language packs from the suite's font store; an Install click is the person's own. wordcraft_engine::packs::use_font_store(true); diff --git a/apps/wordcraft/tests/it/appimage_env.rs b/apps/wordcraft/tests/it/appimage_env.rs new file mode 100644 index 0000000000..e1de2f0040 --- /dev/null +++ b/apps/wordcraft/tests/it/appimage_env.rs @@ -0,0 +1,99 @@ +//! A WordCraft started by an AppImage app inherits that app's `APPIMAGE`, which the self-update +//! would take for this copy's file and replace (#5653). The real program, started both ways. + +use std::ffi::OsString; +use std::os::unix::ffi::OsStringExt; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; +use std::time::{Duration, Instant}; + +const NOTICE: &str = "ignoring the AppImage"; + +/// A folder of its own for one test, removed when it ends. +struct Tmp(PathBuf); + +impl Tmp { + /// An empty folder named after `test` and this process, made afresh. + fn new(test: &str) -> Tmp { + let dir = std::env::temp_dir().join(format!("wordcraft-appimage-it-{test}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + Tmp(dir) + } +} + +impl Drop for Tmp { + /// Removes the folder and what the test left in it. + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// Runs the real app with `args`, as an AppImage app with `appimage` mounted at `appdir` would +/// start it, and waits up to 30 s for it to exit (with no display it can't open a window). +fn app(args: &[OsString], appimage: &Path, appdir: &Path) -> Output { + let mut child = Command::new(env!("CARGO_BIN_EXE_wordcraft")) + .args(args) + .env("APPIMAGE", appimage) + .env("APPDIR", appdir) + .env("ARGV0", "other-app") + .env("OWD", "/") + // With no display, an app that tries to open a window fails at once instead. + .env_remove("DISPLAY") + .env_remove("WAYLAND_DISPLAY") + .env_remove("WAYLAND_SOCKET") + .env_remove("WORDCRAFT_CONTROL_PORT") + .env("WORDCRAFT_NO_PREFS", "1") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + // A start-over that kept the variables would start over for ever, and end here. + let deadline = Instant::now() + Duration::from_secs(30); + while child.try_wait().unwrap().is_none() { + if Instant::now() > deadline { + let _ = child.kill(); + panic!("wordcraft {args:?} was still running after 30 s"); + } + std::thread::sleep(Duration::from_millis(50)); + } + child.wait_with_output().unwrap() +} + +/// The notice comes once: the program it started over as no longer sees the variables. +#[test] +fn another_programs_variables_are_dropped_once_and_the_program_carries_on() { + let tmp = Tmp::new("dropped"); + let other = tmp.0.join("other-app.AppImage"); + std::fs::write(&other, b"another program").unwrap(); + let out = app(&["--version".into()], &other, &tmp.0); + let (stdout, stderr) = (String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)); + assert!(out.status.success(), "status {:?}, stderr: {stderr}", out.status); + assert!(stdout.starts_with("wordcraft "), "stdout: {stdout}"); + assert_eq!(stderr.matches(NOTICE).count(), 1, "stderr: {stderr}"); + assert!(stderr.contains("other-app.AppImage"), "stderr: {stderr}"); + assert_eq!(std::fs::read(&other).unwrap(), b"another program"); +} + +/// Arguments reach the program it starts over as byte for byte, even one that isn't Unicode. +#[test] +fn starting_over_keeps_the_arguments() { + let tmp = Tmp::new("args"); + let out = app(&["--sample".into(), "--control".into(), OsString::from_vec(b"79\xe981".to_vec())], &tmp.0.join("gone"), &tmp.0); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!(out.status.code(), Some(2), "stderr: {stderr}"); + assert!(stderr.contains("argument 3 is not valid Unicode: 79\u{FFFD}81"), "stderr: {stderr}"); +} + +/// Its own image's variables stay, or the self-update couldn't find the file to replace. +#[test] +fn its_own_images_variables_stay() { + let exe = Path::new(env!("CARGO_BIN_EXE_wordcraft")).canonicalize().unwrap(); + let mount = exe.parent().unwrap(); + let out = app(&["--version".into()], &mount.join("WordCraft.AppImage"), mount); + let (stdout, stderr) = (String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)); + assert!(out.status.success(), "status {:?}, stderr: {stderr}", out.status); + assert!(stdout.starts_with("wordcraft "), "stdout: {stdout}"); + assert!(!stderr.contains(NOTICE), "stderr: {stderr}"); +} diff --git a/apps/wordcraft/tests/it/main.rs b/apps/wordcraft/tests/it/main.rs index 9c26dc905b..cfa99b937d 100644 --- a/apps/wordcraft/tests/it/main.rs +++ b/apps/wordcraft/tests/it/main.rs @@ -1,3 +1,5 @@ +#[cfg(target_os = "linux")] +mod appimage_env; mod control_arg; mod help; #[cfg(unix)] diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/updates_ui.rs index ebd03406f6..a22f9657b4 100644 --- a/crates/ui-egui/src/updates_ui.rs +++ b/crates/ui-egui/src/updates_ui.rs @@ -51,6 +51,25 @@ impl WordApp { pub fn set_startup_update_check(&mut self, on: bool) { self.updates_rt.startup_check = on; } + + /// Turns the self-update off for the rest of this process: no update is offered, and Help ▸ + /// Check for Updates says why. For a process that still carries another program's AppImage + /// variables, which the update would take for this copy's file and replace (#5653). + #[cfg(not(target_arch = "wasm32"))] + pub fn disable_self_update(&mut self) { + let rt = &mut self.updates_rt; + rt.startup_check = false; + rt.updater = Updater::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")).with_source(refused_check()); + } +} + +/// A check for updates that never finds one and gives the reason as its error, which Help ▸ Check +/// for Updates shows. +#[cfg(not(target_arch = "wasm32"))] +fn refused_check() -> craft_update::updater::CheckSource { + let why = tl!("Updates are off in this window: WordCraft was started with another program's AppImage settings. Start WordCraft from its own menu entry to update it.") + .to_string(); + std::sync::Arc::new(move || Err(why.clone())) } pub(crate) fn check(app: &mut WordApp) { @@ -134,3 +153,29 @@ pub(crate) fn show(app: &mut WordApp, ctx: &egui::Context) { } } } + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use craft_update::updater::Status; + + /// With the self-update off, a startup check is never made, and Help ▸ Check for Updates (the + /// command's own path) ends in the reason instead of a release to install (#5653). + #[test] + fn with_the_self_update_off_a_check_finds_nothing_and_says_why() { + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default()); + app.set_startup_update_check(true); + app.disable_self_update(); + assert!(!app.updates_rt.startup_check, "no check at start"); + let ctx = egui::Context::default(); + super::check(&mut app); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while !matches!(app.updates_rt.updater.status(), Status::Failed(_)) { + assert!(std::time::Instant::now() < deadline, "the check never ended: {:?}", app.updates_rt.updater.status()); + super::tick(&mut app, &ctx); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + let status = app.updates_rt.updater.status(); + assert!(matches!(status, Status::Failed(why) if why.contains("another program's AppImage")), "{status:?}"); + assert!(!app.updates_rt.updater.prompt_open(), "no update offered"); + } +}