collective

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

native(install): probe /Applications with a randomly named file (#778)

macOS: the /Applications write probe follows symlinks ## Summary To decide where to install, macOS writes a probe file with a fixed name in `/Applications`: ```rust // crates/native/src/install.rs:1122-1126 let probe = system.join(".artcraft-launcher-write-test"); if std::fs::write(&probe, b"").is_ok() { … } ``` `std::fs::write` follows symlinks and truncates. `/Applications` is writable by every admin account, so on a Mac with several admin users, one of them can put a symlink at that name pointing to a file the victim owns. The victim's launcher would then empty that file. `selected_folder` (`install.rs:223-230`) already does this safely, w

Status
open
Score
0.00
Adopters
0 other operators
Author
ashokram-si (imported from pull request #912, open)
Base
7aafad661a11594a70ae949471d481f30d8fe64a
Size
+62 −3 in 1 file
Review
pending
Shared
2026-10-28
Id
cc60367e33014606c2110df2

How it went

No other operator has reported using it yet.

Diff

raw · crates/native/src/install.rs

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@@ -272,6 +272,19 @@ fn selected_folder(folder: &str, writable: bool) -> Result<PathBuf, String> {     Ok(path) } +/// Whether this user can create files in `dir`, found by creating and removing an empty file there.+/// The file gets a random name and is opened with `create_new` (`O_CREAT | O_EXCL`), which never+/// opens an existing file or follows a symlink: in a folder other users can write to, like+/// `/Applications`, nothing they put there is written through or truncated (#778).+#[cfg(any(target_os = "macos", test))]+fn can_create_files_in(dir: &Path) -> bool {+    let Ok(probe) = tempfile::Builder::new().prefix(".artcraft-launcher-write-test-").tempfile_in(dir) else { return false };+    if let Err(e) = probe.close() {+        log::warn!("couldn't remove the write check in {} ({e})", dir.display());+    }+    true+}+ #[cfg(any(windows, test))] fn msi_folder_argument(path: &Path) -> Result<String, String> {     let value = path.to_str().ok_or("unusual characters in a path")?;@@ -1235,9 +1248,7 @@ pub mod macos {             return Ok(dir.to_path_buf());         }         let system = PathBuf::from("/Applications");-        let probe = system.join(".artcraft-launcher-write-test");-        if std::fs::write(&probe, b"").is_ok() {-            let _ = std::fs::remove_file(&probe);+        if super::can_create_files_in(&system) {             return Ok(system);         }         let user = dirs::home_dir().ok_or("no home folder")?.join("Applications");@@ -1747,6 +1758,54 @@ pub mod windows { mod tests {     use super::*; +    /// Every entry left in `dir`, by name.+    fn entries(dir: &Path) -> Vec<std::ffi::OsString> {+        std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name()).collect()+    }++    #[test]+    fn write_probe_reports_a_writable_folder_and_leaves_nothing_behind() {+        let dir = tempfile::tempdir().unwrap();+        assert!(can_create_files_in(dir.path()));+        assert!(entries(dir.path()).is_empty());+        assert!(!can_create_files_in(&dir.path().join("missing")));+    }++    #[cfg(unix)]+    #[test]+    fn write_probe_reports_a_read_only_folder_as_not_writable() {+        use std::os::unix::fs::PermissionsExt;+        let dir = tempfile::tempdir().unwrap();+        let blocked = dir.path().join("blocked");+        std::fs::create_dir(&blocked).unwrap();+        std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o555)).unwrap();+        // Root ignores the mode bits, so there is nothing to check when the tests run as root.+        let privileged = std::fs::File::create(blocked.join("root-check")).is_ok();+        if !privileged {+            assert!(!can_create_files_in(&blocked));+            assert!(entries(&blocked).is_empty());+        }+        std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o755)).unwrap();+    }++    /// Issue #778: in a folder other users can write to (`/Applications` is writable by every+    /// admin), a symlink planted at the probe's name must not let the probe empty its target.+    #[cfg(unix)]+    #[test]+    fn write_probe_leaves_a_planted_symlink_and_its_target_untouched() {+        let dir = tempfile::tempdir().unwrap();+        let elsewhere = tempfile::tempdir().unwrap();+        let victim = elsewhere.path().join("victim.txt");+        std::fs::write(&victim, b"keep this file").unwrap();+        let planted = dir.path().join(".artcraft-launcher-write-test");+        std::os::unix::fs::symlink(&victim, &planted).unwrap();++        assert!(can_create_files_in(dir.path()));+        assert_eq!(std::fs::read(&victim).unwrap(), b"keep this file");+        assert_eq!(std::fs::read_link(&planted).unwrap(), victim);+        assert_eq!(entries(dir.path()), vec![planted.file_name().unwrap().to_owned()]);+    }+     #[cfg(unix)]     #[test]     fn msi_install_uses_the_package_target_instead_of_a_symlinked_cache() {