collective

Browse / github.com/storytold/craft-launcher / patch

Match native Linux app processes without locale-dependent text

Match native Linux app processes without locale-dependent text Native Linux process checks can miss a running app whose path contains Unicode when the launcher inherits the C locale. That check protects updates and removal. The existing live-process regression reproduces the missed match without installing or removing an app. Read native Linux command lines directly from procfs and match the path as literal bytes. Keep wrapper arguments, spaces and regex characters working, skip the launcher's own process, and carry matches across fixed-size reads. The scan uses a cooperative five-second deadline. It reports an incomplete scan as an error so replacement

Status
open
Score
0.00
Adopters
0 other operators
Author
IanHollow (imported from pull request #482, open)
Base
307efcf45a080af9643e750d43034eb25540cef8
Size
+286 −8 in 13 files
Review
pending
Shared
2026-10-28
Id
82109feedaeb7be7c926ead2

How it went

No other operator has reported using it yet.

Diff

raw · crates/native/src/install.rs crates/native/src/process.rs crates/native/src/process/linux.rs crates/ui-egui/src/i18n/cs.tsv crates/ui-egui/src/i18n/de.tsv crates/ui-egui/src/i18n/es.tsv crates/ui-egui/src/i18n/fr.tsv crates/ui-egui/src/i18n/hu.tsv crates/ui-egui/src/i18n/ja.tsv crates/ui-egui/src/i18n/pl.tsv crates/ui-egui/src/i18n/pt-BR.tsv crates/ui-egui/src/i18n/ru.tsv crates/ui-egui/src/i18n/zh-CN.tsv

diff --git a/crates/native/src/install.rs b/crates/native/src/install.rs--- a/crates/native/src/install.rs+++ b/crates/native/src/install.rs@@ -77,9 +77,8 @@ pub fn install_at(     let destination = destination(app, kind, existing, previous, job.install_locations)?;     if let Some(inst) = existing         && inst.source.launcher_can_update()-        && process::is_running(Path::new(&inst.location))     {-        return Err(format!("{} is running. Quit it, then try again.", app.name));+        process::ensure_not_running(Path::new(&inst.location), app.name)?;     }     log::info!("{}: starting {} to {} ({})", app.name, if existing.is_some() { "update" } else { "install" }, release.version_label(), kind.label());     let file = download(release, asset, job)?;@@ -360,10 +359,8 @@ pub fn uninstall(     if let Some(msg) = managed_elsewhere(app, installed, true) {         return Err(msg);     }-    if matches!(installed.source, InstallSource::MacApp | InstallSource::Msi | InstallSource::AppImage | InstallSource::Launcher)-        && process::is_running(&path)-    {-        return Err(format!("{} is running. Quit it, then try again.", app.name));+    if matches!(installed.source, InstallSource::MacApp | InstallSource::Msi | InstallSource::AppImage | InstallSource::Launcher) {+        process::ensure_not_running(&path, app.name)?;     }     match installed.source {         #[cfg(windows)]@@ -1666,6 +1663,34 @@ mod tests {         assert_eq!(phases.iter().filter(|p| **p == JobPhase::Installing).count(), 2);     } +    #[cfg(target_os = "linux")]+    #[test]+    fn an_uncheckable_process_blocks_install_and_uninstall() {+        // This exercises native procfs, not Flatpak's separate host helper.+        if process::in_sandbox() {+            eprintln!("native Linux process check requires running outside Flatpak");+            return;+        }+        let app = CraftApp::by_id("wordcraft").unwrap();+        let existing = Installed { version: None, source: InstallSource::AppImage, location: String::new(), package: None };+        let dir = tempfile::tempdir().unwrap();+        let paths = Paths::under(dir.path());+        let http = Http::new().unwrap();+        let stop = Stop::default();+        let mut phases = Vec::new();+        let mut phase = |p: JobPhase| phases.push(p);+        let locations = InstallLocations::default();+        let mut job = Job { http: &http, paths: &paths, stop: &stop, phase: &mut phase, install_locations: &locations, replace: false };+        let expected = format!("Couldn't check whether {} is running. Try again.", app.name);+        assert_eq!(+            install(app, &Release::default(), &Asset::default(), PackageKind::AppImage, Some(&existing), None, &mut job).unwrap_err(),+            expected+        );+        assert_eq!(uninstall(app, &existing, None, &paths, &stop, &mut phase).unwrap_err(), expected);+        assert!(phases.is_empty(), "neither operation reaches a download or installer phase");+        assert!(!paths.cache.exists());+    }+     #[test]     fn an_unavailable_custom_folder_is_rejected_before_downloading() {         use crate::http::tests::{Reply, body, serve};diff --git a/crates/native/src/process.rs b/crates/native/src/process.rs--- a/crates/native/src/process.rs+++ b/crates/native/src/process.rs@@ -226,7 +226,30 @@ fn explorer_select_arg(path: &Path) -> Option<std::ffi::OsString> { }  /// Whether a process started from inside `path` (an exe, an AppImage, or a `.app`) is running.-pub fn is_running(path: &Path) -> bool {+pub fn is_running(path: &Path) -> std::io::Result<bool> {+    #[cfg(target_os = "linux")]+    if !in_sandbox() {+        return linux::is_running(path);+    }+    Ok(is_running_with_helper(path))+}++/// Refuse a replacement or removal if an app is running or its state cannot be checked.+pub fn ensure_not_running(path: &Path, name: &str) -> Result<(), String> {+    match is_running(path) {+        Ok(false) => Ok(()),+        Ok(true) => Err(format!("{name} is running. Quit it, then try again.")),+        Err(error) => {+            log::warn!("couldn't check running application {}: {error}", path.display());+            Err(format!("Couldn't check whether {name} is running. Try again."))+        }+    }+}++#[cfg(target_os = "linux")]+mod linux;++fn is_running_with_helper(path: &Path) -> bool {     if cfg!(windows) {         let Some(name) = path.file_name().and_then(|n| n.to_str()) else { return false };         let filter = format!("IMAGENAME eq {name}");@@ -297,13 +320,15 @@ mod tests {             ("plain/PhotoCraft.app", "plain/PhotoCraft.app", true),             ("Apps (work)/PhotoCraft.app", "Apps (work)/PhotoCraft.app", true),             ("Apps [work]/PhotoCraft.app", "Apps [work]/PhotoCraft.app", true),+            ("trailing/PhotoCraft.app ", "trailing/PhotoCraft.app ", true),+            ("trailing/PhotoCraft.app", "trailing/PhotoCraft.app ", false),             ("other/PhotoCraftXapp", "other/PhotoCraft.app", false),             ("symbols/应用.^$*+?()[]{}|\\.app", "symbols/应用.^$*+?()[]{}|\\.app", true),         ] {             let actual = format!("{root}/{actual}");             let requested = format!("{root}/{requested}");             let _probe = RunningProbe::start(&actual);-            assert_eq!(is_running(Path::new(&requested)), expected, "{requested}");+            assert_eq!(is_running(Path::new(&requested)).unwrap(), expected, "{requested}");         }     } }diff --git a/crates/native/src/process/linux.rs b/crates/native/src/process/linux.rsnew file mode 100644--- /dev/null+++ b/crates/native/src/process/linux.rs@@ -0,0 +1,208 @@+//! Native Linux process checks read command-line bytes rather than locale-dependent display text.+use std::ffi::OsStr;+use std::io::{self, Read};+use std::os::unix::ffi::OsStrExt;+use std::path::Path;+use std::time::{Duration, Instant};++pub(super) fn is_running(path: &Path) -> io::Result<bool> {+    scan(Path::new("/proc"), path.as_os_str().as_bytes(), std::process::id(), Instant::now() + Duration::from_secs(5))+}++fn scan(root: &Path, query: &[u8], own_pid: u32, deadline: Instant) -> io::Result<bool> {+    let mut matcher = Matcher::new(query)?;+    let mut incomplete = None;+    for entry in std::fs::read_dir(root)? {+        check_deadline(deadline)?;+        let entry = entry?;+        let name = entry.file_name();+        let Some(pid) = numeric_pid(&name) else { continue };+        if pid == own_pid {+            continue;+        }+        matcher.reset();+        let result = std::fs::File::open(entry.path().join("cmdline")).and_then(|file| matches_command_line(file, &mut matcher, deadline));+        match result {+            Ok(true) => return Ok(true),+            Ok(false) => {}+            // Processes can exit during the scan. As with pgrep, inaccessible processes are+            // outside this best-effort observation, including hidepid-protected processes.+            Err(error) if process_unavailable(&error) => {}+            Err(error) => incomplete = Some(error),+        }+    }+    check_deadline(deadline)?;+    match incomplete {+        Some(error) => Err(error),+        None => Ok(false),+    }+}++fn process_unavailable(error: &io::Error) -> bool {+    // ESRCH is Linux errno 3. A task may disappear after its cmdline file was opened.+    error.raw_os_error() == Some(3) || matches!(error.kind(), io::ErrorKind::NotFound | io::ErrorKind::PermissionDenied)+}++fn numeric_pid(name: &OsStr) -> Option<u32> {+    let bytes = name.as_bytes();+    if bytes.is_empty() || !bytes.iter().all(u8::is_ascii_digit) {+        return None;+    }+    std::str::from_utf8(bytes).ok()?.parse().ok()+}++fn check_deadline(deadline: Instant) -> io::Result<()> {+    if Instant::now() >= deadline { Err(io::Error::new(io::ErrorKind::TimedOut, "process scan did not finish within five seconds")) } else { Ok(()) }+}++fn matches_command_line(mut reader: impl Read, matcher: &mut Matcher<'_>, deadline: Instant) -> io::Result<bool> {+    let mut buffer = [0; 8192];+    let mut separators = 0usize;+    loop {+        check_deadline(deadline)?;+        let count = match reader.read(&mut buffer) {+            Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,+            result => result?,+        };+        if count == 0 {+            check_deadline(deadline)?;+            // procps drops all terminal NULs, including empty final arguments. Actual spaces+            // in the final argument remain searchable; they have already reached the matcher.+            return Ok(false);+        }+        for &byte in &buffer[..count] {+            if byte == 0 {+                separators =+                    separators.checked_add(1).ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "too many command-line separators"))?;+                continue;+            }+            while separators > 0 {+                if separators % buffer.len() == 0 {+                    check_deadline(deadline)?;+                }+                if matcher.feed(b' ') {+                    return Ok(true);+                }+                separators -= 1;+            }+            if matcher.feed(byte) {+                return Ok(true);+            }+        }+    }+}++/// A linear-time literal search carrying only query-sized state between fixed-size reads.+struct Matcher<'a> {+    query: &'a [u8],+    prefix: Vec<usize>,+    matched: usize,+}++impl<'a> Matcher<'a> {+    fn new(query: &'a [u8]) -> io::Result<Self> {+        if query.is_empty() || query.contains(&0) {+            return Err(io::Error::new(io::ErrorKind::InvalidInput, "application path is empty or contains NUL"));+        }+        let mut prefix = Vec::new();+        prefix.try_reserve_exact(query.len()).map_err(io::Error::other)?;+        prefix.resize(query.len(), 0);+        let mut matched = 0;+        for index in 1..query.len() {+            while matched > 0 && query.get(index) != query.get(matched) {+                matched = prefix[matched - 1];+            }+            if query.get(index) == query.get(matched) {+                matched += 1;+            }+            prefix[index] = matched;+        }+        Ok(Self { query, prefix, matched: 0 })+    }++    fn reset(&mut self) {+        self.matched = 0;+    }++    fn feed(&mut self, byte: u8) -> bool {+        while self.matched > 0 && self.query.get(self.matched) != Some(&byte) {+            self.matched = self.prefix[self.matched - 1];+        }+        if self.query.get(self.matched) == Some(&byte) {+            self.matched += 1;+        }+        self.matched == self.query.len()+    }+}++#[cfg(test)]+mod tests {+    use super::*;++    fn deadline() -> Instant {+        Instant::now() + Duration::from_secs(5)+    }++    fn matches(bytes: &[u8], query: &[u8]) -> bool {+        matches_command_line(bytes, &mut Matcher::new(query).unwrap(), deadline()).unwrap()+    }++    #[test]+    fn literal_command_lines_preserve_wrappers_and_argument_spacing() {+        let bytes = b"/bin/sh\0/Apps (work)/\xe5\xba\x94\xe7\x94\xa8.AppImage\0--name\0\0Inter\0";+        assert!(matches(bytes, b"/Apps (work)/\xe5\xba\x94\xe7\x94\xa8.AppImage"));+        assert!(matches(bytes, b"--name  Inter"));+        assert!(!matches(bytes, b"Inter "));+        assert!(!matches(bytes, b"/Apps (work)/\xe5\xba\x94\xe7\x94\xa8XAppImage"));+        assert!(matches(b"/fonts/\xff/app\0", b"/fonts/\xff/app"));+        assert!(matches(b"prefix\0/Apps/PhotoCraft.app/Contents/MacOS/app\0", b"/Apps/PhotoCraft.app"));+        assert!(!matches(b"", b"app"));+        assert!(!matches(b"/app\0\0\0", b"/app "));+        assert!(matches(b"/app \0\0\0", b"/app "));+        assert!(matches(b"/app\0\0tail\0\0", b"/app  tail"));+    }++    #[test]+    fn matches_across_reads_without_retaining_the_command_line() {+        let mut bytes = vec![b'x'; 8191];+        bytes.extend_from_slice(b"/path with spaces/app\0");+        assert!(matches(&bytes, b"/path with spaces/app"));+        assert!(matches(b"aaaaab\0", b"aaab"));+        let mut separated = vec![b'x'; 8191];+        separated.extend_from_slice(b"\0\0tail\0\0");+        assert!(matches(&separated, b"x  tail"));+        assert!(!matches(&separated, b"tail "));+        assert!(Matcher::new(b"").is_err());+        assert!(Matcher::new(b"/bad\0path").is_err());+    }++    #[test]+    fn incomplete_scans_are_errors_and_names_are_numeric() {+        let mut matcher = Matcher::new(b"app").unwrap();+        assert_eq!(matches_command_line(&b"app\0"[..], &mut matcher, Instant::now()).unwrap_err().kind(), io::ErrorKind::TimedOut);+        assert!(scan(Path::new("/proc/no-such-artcraft-process-root"), b"app", 0, deadline()).is_err());+        assert_eq!(numeric_pid(OsStr::new("123")), Some(123));+        assert_eq!(numeric_pid(OsStr::new("123x")), None);+        assert_eq!(numeric_pid(OsStr::new("self")), None);+        assert_eq!(numeric_pid(OsStr::new("")), None);+        assert!(process_unavailable(&io::Error::from_raw_os_error(3)));+        assert!(process_unavailable(&io::Error::from(io::ErrorKind::NotFound)));+        assert!(process_unavailable(&io::Error::from(io::ErrorKind::PermissionDenied)));+        assert!(!process_unavailable(&io::Error::from(io::ErrorKind::Other)));+    }++    #[test]+    fn scanning_skips_self_and_disappeared_entries() {+        let temp = tempfile::tempdir().unwrap();+        for pid in [12, 13] {+            std::fs::create_dir(temp.path().join(pid.to_string())).unwrap();+        }+        std::fs::write(temp.path().join("12/cmdline"), b"/app\0").unwrap();+        assert!(!scan(temp.path(), b"/app", 12, deadline()).unwrap());+        std::fs::write(temp.path().join("13/cmdline"), b"/app\0").unwrap();+        assert!(scan(temp.path(), b"/app", 12, deadline()).unwrap());+        std::fs::remove_file(temp.path().join("13/cmdline")).unwrap();+        std::fs::create_dir(temp.path().join("13/cmdline")).unwrap();+        assert!(scan(temp.path(), b"/app", 12, deadline()).is_err());+    }+}diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv--- a/crates/ui-egui/src/i18n/cs.tsv+++ b/crates/ui-egui/src/i18n/cs.tsv@@ -330,3 +330,5 @@ filter	Installed	Nainstalováno 	Replace what's there?	Nahradit, co tam je? 	{path} already exists. Installing {name} will overwrite it.	{path} už existuje. Instalace aplikace {name} ho přepíše. 	Replace	Nahradit++@msg	Couldn't check whether {name} is running. Try again.	Nepodařilo se zjistit, zda je aplikace {name} spuštěná. Zkuste to znovu.diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv--- a/crates/ui-egui/src/i18n/de.tsv+++ b/crates/ui-egui/src/i18n/de.tsv@@ -362,3 +362,5 @@ filter	Installed	Installiert 	Replace what's there?	Vorhandenes ersetzen? 	{path} already exists. Installing {name} will overwrite it.	{path} existiert bereits. Die Installation von {name} überschreibt es. 	Replace	Ersetzen++@msg	Couldn't check whether {name} is running. Try again.	Es konnte nicht geprüft werden, ob {name} läuft. Versuche es erneut.diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv--- a/crates/ui-egui/src/i18n/es.tsv+++ b/crates/ui-egui/src/i18n/es.tsv@@ -343,3 +343,5 @@ filter	Installed	Instaladas 	Replace what's there?	¿Reemplazar lo que hay? 	{path} already exists. Installing {name} will overwrite it.	{path} ya existe. Instalar {name} lo sobrescribirá. 	Replace	Reemplazar++@msg	Couldn't check whether {name} is running. Try again.	No se pudo comprobar si {name} está abierta. Inténtalo de nuevo.diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv--- a/crates/ui-egui/src/i18n/fr.tsv+++ b/crates/ui-egui/src/i18n/fr.tsv@@ -360,3 +360,5 @@ filter	Installed	Installées 	Replace what's there?	Remplacer l’existant ? 	{path} already exists. Installing {name} will overwrite it.	{path} existe déjà. L’installation de {name} l’écrasera. 	Replace	Remplacer++@msg	Couldn't check whether {name} is running. Try again.	Impossible de vérifier si {name} est en cours d'exécution. Réessayez.diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv--- a/crates/ui-egui/src/i18n/hu.tsv+++ b/crates/ui-egui/src/i18n/hu.tsv@@ -363,3 +363,5 @@ filter	Installed	Telepítve 	Replace what's there?	Lecseréli, ami ott van? 	{path} already exists. Installing {name} will overwrite it.	{path} már létezik. A(z) {name} telepítése felülírja. 	Replace	Csere++@msg	Couldn't check whether {name} is running. Try again.	Nem sikerült ellenőrizni, hogy a(z) {name} fut-e. Próbáld újra.diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv--- a/crates/ui-egui/src/i18n/ja.tsv+++ b/crates/ui-egui/src/i18n/ja.tsv@@ -365,3 +365,5 @@ filter	Installed	インストール済み 	Replace what's there?	既存の項目を置き換えますか? 	{path} already exists. Installing {name} will overwrite it.	{path} は既に存在します。{name} をインストールすると上書きされます。 	Replace	置き換える++@msg	Couldn't check whether {name} is running. Try again.	{name} が実行中かどうか確認できませんでした。もう一度お試しください。diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv--- a/crates/ui-egui/src/i18n/pl.tsv+++ b/crates/ui-egui/src/i18n/pl.tsv@@ -364,3 +364,5 @@ filter	Installed	Zainstalowane 	Replace what's there?	Zastąpić to, co tam jest? 	{path} already exists. Installing {name} will overwrite it.	{path} już istnieje. Instalacja {name} go nadpisze. 	Replace	Zastąp++@msg	Couldn't check whether {name} is running. Try again.	Nie można sprawdzić, czy aplikacja {name} jest uruchomiona. Spróbuj ponownie.diff --git a/crates/ui-egui/src/i18n/pt-BR.tsv b/crates/ui-egui/src/i18n/pt-BR.tsv--- a/crates/ui-egui/src/i18n/pt-BR.tsv+++ b/crates/ui-egui/src/i18n/pt-BR.tsv@@ -350,3 +350,5 @@ filter	Installed	Instalados 	Replace what's there?	Substituir o que já existe? 	{path} already exists. Installing {name} will overwrite it.	{path} já existe. Instalar o {name} vai substituí-lo. 	Replace	Substituir++@msg	Couldn't check whether {name} is running. Try again.	Não foi possível verificar se {name} está em execução. Tente novamente.diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv--- a/crates/ui-egui/src/i18n/ru.tsv+++ b/crates/ui-egui/src/i18n/ru.tsv@@ -358,3 +358,5 @@ filter	Installed	Установлены 	Replace what's there?	Заменить существующее? 	{path} already exists. Installing {name} will overwrite it.	{path} уже существует. Установка {name} перезапишет его. 	Replace	Заменить++@msg	Couldn't check whether {name} is running. Try again.	Не удалось проверить, запущено ли приложение {name}. Повторите попытку.diff --git a/crates/ui-egui/src/i18n/zh-CN.tsv b/crates/ui-egui/src/i18n/zh-CN.tsv--- a/crates/ui-egui/src/i18n/zh-CN.tsv+++ b/crates/ui-egui/src/i18n/zh-CN.tsv@@ -330,3 +330,5 @@ figcraft	Interface design and prototyping	界面设计与原型制作 	Replace what's there?	替换现有内容? 	{path} already exists. Installing {name} will overwrite it.	{path} 已存在。安装 {name} 将覆盖它。 	Replace	替换++@msg	Couldn't check whether {name} is running. Try again.	无法检查 {name} 是否正在运行。请重试。