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
@@ -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() {