diff --git a/Cargo.lock b/Cargo.lock index 0b3371a..547c5d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -225,9 +225,11 @@ dependencies = [ "axoprocess", "axotag", "camino", + "directories", "gazenot", "homedir", "httpmock", + "libc", "miette", "self-replace", "serde", @@ -548,6 +550,27 @@ dependencies = [ "subtle", ] +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1268,6 +1291,16 @@ version = "0.2.174" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags 2.4.2", + "libc", +] + [[package]] name = "libz-rs-sys" version = "0.5.2" @@ -1452,6 +1485,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "overload" version = "0.1.1" @@ -1678,6 +1717,17 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 2.0.17", +] + [[package]] name = "regex" version = "1.11.0" @@ -2277,7 +2327,7 @@ dependencies = [ "getrandom 0.3.3", "once_cell", "rustix 1.0.8", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] diff --git a/axoupdater-cli/tests/integration.rs b/axoupdater-cli/tests/integration.rs index 1776e8e..3c4ede9 100644 --- a/axoupdater-cli/tests/integration.rs +++ b/axoupdater-cli/tests/integration.rs @@ -58,6 +58,16 @@ fn write_receipt( Ok(()) } +fn executable_tempdir() -> std::io::Result { + if let Some(runtime_dir) = std::env::var_os("XDG_RUNTIME_DIR") { + tempfile::Builder::new() + .prefix("axoupdater-exec") + .tempdir_in(runtime_dir) + } else { + TempDir::new() + } +} + #[test] fn bails_out_with_default_name() { let mut command = Cmd::new(BIN, "execute axoupdater"); @@ -81,7 +91,7 @@ fn bails_out_with_default_name() { // several noteworthy bugfixes in its installer. #[test] fn test_upgrade() -> std::io::Result<()> { - let tempdir = TempDir::new()?; + let tempdir = executable_tempdir()?; let bindir_path = &tempdir.path().join("bin"); let bindir = Utf8Path::from_path(bindir_path).unwrap(); std::fs::create_dir_all(bindir)?; @@ -144,7 +154,7 @@ fn test_upgrade() -> std::io::Result<()> { #[test] fn test_upgrade_xdg_config_home() -> std::io::Result<()> { - let tempdir = TempDir::new()?; + let tempdir = executable_tempdir()?; let bindir_path = &tempdir.path().join("bin"); let bindir = Utf8Path::from_path(bindir_path).unwrap(); std::fs::create_dir_all(bindir)?; @@ -209,7 +219,7 @@ fn test_upgrade_xdg_config_home() -> std::io::Result<()> { #[test] fn test_upgrade_allow_prerelease() -> std::io::Result<()> { - let tempdir = TempDir::new()?; + let tempdir = executable_tempdir()?; let bindir_path = &tempdir.path().join("bin"); let bindir = Utf8Path::from_path(bindir_path).unwrap(); std::fs::create_dir_all(bindir)?; @@ -276,7 +286,8 @@ fn test_upgrade_allow_prerelease() -> std::io::Result<()> { #[test] fn test_upgrade_to_specific_version() -> std::io::Result<()> { let tempdir = TempDir::new()?; - let bindir_path = &tempdir.path().join("bin"); + let executable_tmp = executable_tempdir()?; + let bindir_path = &executable_tmp.path().join("bin"); let bindir = Utf8Path::from_path(bindir_path).unwrap(); std::fs::create_dir_all(bindir)?; @@ -334,7 +345,7 @@ fn test_upgrade_to_specific_version() -> std::io::Result<()> { // version on request instead of upgrading. #[test] fn test_downgrade_to_specific_version() -> std::io::Result<()> { - let tempdir = TempDir::new()?; + let tempdir = executable_tempdir()?; let bindir_path = &tempdir.path().join("bin"); let bindir = Utf8Path::from_path(bindir_path).unwrap(); std::fs::create_dir_all(bindir)?; @@ -406,7 +417,7 @@ fn test_downgrade_to_specific_old_version() -> std::io::Result<()> { _ => return Ok(()), } - let tempdir = TempDir::new()?; + let tempdir = executable_tempdir()?; let bindir_path = &tempdir.path().join("bin"); let bindir = Utf8Path::from_path(bindir_path).unwrap(); std::fs::create_dir_all(bindir)?; @@ -469,7 +480,7 @@ fn test_downgrade_to_specific_old_version() -> std::io::Result<()> { // https://github.com/axodotdev/cargo-dist/pull/1037 #[test] fn test_upgrade_from_prefix_with_no_bin() -> std::io::Result<()> { - let tempdir = TempDir::new()?; + let tempdir = executable_tempdir()?; let prefix = Utf8PathBuf::from_path_buf(tempdir.path().to_path_buf()).unwrap(); let bindir_path = &tempdir.path().join("bin"); let bindir = Utf8Path::from_path(bindir_path).unwrap(); diff --git a/axoupdater/Cargo.toml b/axoupdater/Cargo.toml index a40b2be..9d20bfc 100644 --- a/axoupdater/Cargo.toml +++ b/axoupdater/Cargo.toml @@ -36,10 +36,14 @@ tokio = { version = "1.48.0", features = ["full"], optional = true } # errors miette = "7.2.0" thiserror = "2.0.0" +directories = "6.0.0" [target.'cfg(windows)'.dependencies] self-replace = "1.5.0" +[target.'cfg(unix)'.dependencies] +libc = "0.2" + [dev-dependencies] tokio = { version = "1.48.0", features = ["test-util"] } httpmock = "0.8.2" diff --git a/axoupdater/src/lib.rs b/axoupdater/src/lib.rs index 4148c6f..9d3a745 100644 --- a/axoupdater/src/lib.rs +++ b/axoupdater/src/lib.rs @@ -6,6 +6,7 @@ pub mod errors; mod receipt; mod release; +mod staging_dir; pub mod test; pub use errors::*; @@ -27,8 +28,7 @@ use axoasset::LocalAsset; use axoprocess::Cmd; pub use axotag::Version; use camino::Utf8PathBuf; - -use tempfile::TempDir; +use staging_dir::select_installer_tempdir; /// Version number for this release of axoupdater. pub const VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -420,7 +420,13 @@ impl AxoUpdater { self.requested_release.as_ref().unwrap() } }; - let tempdir = TempDir::new()?; + let tempdir_parent_name = self + .source + .as_ref() + .map(|source| source.name.as_str()) + .or(self.name.as_deref()) + .unwrap_or("axoupdater"); + let mut _staging_dir_guard = None; // If we've been given an installer path to use, skip downloading and // install from that. @@ -429,6 +435,7 @@ impl AxoUpdater { // Otherwise, proceed with downloading the installer from the release // we just looked up. } else { + let tempdir = select_installer_tempdir(tempdir_parent_name)?; let app_name = self.name.clone().unwrap_or_default(); let installer_url = match env::consts::OS { "macos" | "linux" => release @@ -474,6 +481,7 @@ impl AxoUpdater { .await?; LocalAsset::write_new_all(&download, &installer_path)?; + _staging_dir_guard = Some(tempdir); installer_path }; diff --git a/axoupdater/src/staging_dir.rs b/axoupdater/src/staging_dir.rs new file mode 100644 index 0000000..ad5eb2e --- /dev/null +++ b/axoupdater/src/staging_dir.rs @@ -0,0 +1,189 @@ +//! Installer staging directory selection and lifecycle helpers. + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::Stdio, +}; + +#[cfg(unix)] +use std::{os::unix::fs::PermissionsExt, process::Command}; + +use directories::BaseDirs; +use tempfile::TempDir; + +use crate::errors::AxoupdateResult; + +/// Selects an executable staging tempdir for installer downloads. +/// +/// Tries runtime/cache/data-local roots first, then falls back to the +/// process-global temporary directory. +pub(crate) fn select_installer_tempdir(parent_name: &str) -> AxoupdateResult { + let name_prefix = tempdir_name_prefix(parent_name); + for candidate in prioritized_staging_roots() { + if let Ok(tempdir) = tempfile::Builder::new() + .prefix(&name_prefix) + .tempdir_in(candidate) + { + if can_execute_from_dir(tempdir.path()) { + return Ok(tempdir); + } + } + } + + let tempdir = TempDir::new()?; + if can_execute_from_dir(tempdir.path()) { + Ok(tempdir) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "unable to find an executable temp directory", + ) + .into()) + } +} + +fn prioritized_staging_roots() -> Vec { + let Some(base_dirs) = BaseDirs::new() else { + return Vec::new(); + }; + + let mut dirs = Vec::new(); + + if let Some(runtime_dir) = runtime_dir_with_unix_fallback(&base_dirs) { + // On Linux, XDG says runtime dir "must" exist, be user owned, user-session scoped, + // and be 700 (i.e. executable). Generally seems most appropriate. + dirs.push(runtime_dir); + } + dirs.push(base_dirs.cache_dir().to_path_buf()); + dirs.push(base_dirs.data_local_dir().to_path_buf()); + + dirs +} + +fn tempdir_name_prefix(parent_name: &str) -> String { + format!("{}-axoupdate-", sanitized_parent_component(parent_name)) +} + +fn sanitized_parent_component(parent_name: &str) -> String { + let mut safe = parent_name + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') { + ch + } else { + '_' + } + }) + .collect::(); + if safe.is_empty() || safe.chars().all(|ch| ch == '.') { + safe = "axoupdater".to_owned(); + } + safe +} + +fn runtime_dir_with_unix_fallback(base_dirs: &BaseDirs) -> Option { + if let Some(runtime_dir) = base_dirs.runtime_dir() { + return Some(runtime_dir.to_path_buf()); + } + + #[cfg(unix)] + { + if env::var_os("XDG_RUNTIME_DIR").is_none() { + let uid = unsafe { libc::geteuid() }; + let fallback = PathBuf::from(format!("/run/user/{uid}")); + if fallback.is_dir() { + return Some(fallback); + } + } + } + + None +} + +#[cfg(unix)] +fn can_execute_from_dir(dir: &Path) -> bool { + let script_path = dir.join(format!("exec-probe-{}.sh", std::process::id())); + let script_contents = "#!/bin/sh\nexit 0\n"; + + if fs::write(&script_path, script_contents).is_err() { + return false; + } + + let chmod_result = fs::set_permissions(&script_path, fs::Permissions::from_mode(0o700)); + if chmod_result.is_err() { + let _ = fs::remove_file(&script_path); + return false; + } + + let ran = Command::new(&script_path) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()); + + let _ = fs::remove_file(&script_path); + ran +} + +#[cfg(not(unix))] +fn can_execute_from_dir(dir: &Path) -> bool { + dir.is_dir() +} + +#[cfg(test)] +mod tests { + use directories::BaseDirs; + + #[cfg(unix)] + use std::{fs, os::unix::fs::PermissionsExt}; + + use super::{ + prioritized_staging_roots, runtime_dir_with_unix_fallback, sanitized_parent_component, + tempdir_name_prefix, + }; + + #[test] + fn test_staging_dir_priorities_follow_base_dirs_order() { + let Some(base_dirs) = BaseDirs::new() else { + return; + }; + let mut expected = Vec::new(); + if let Some(runtime_dir) = runtime_dir_with_unix_fallback(&base_dirs) { + expected.push(runtime_dir); + } + expected.push(base_dirs.cache_dir().to_path_buf()); + expected.push(base_dirs.data_local_dir().to_path_buf()); + + let actual = prioritized_staging_roots(); + assert_eq!(actual, expected); + } + + #[test] + fn test_parent_component_is_sanitized() { + assert_eq!( + sanitized_parent_component("../parent/workspace"), + ".._parent_workspace" + ); + assert_eq!(sanitized_parent_component(""), "axoupdater"); + } + + #[test] + fn test_tempdir_name_prefix_shape() { + assert_eq!( + tempdir_name_prefix("parent-workspace"), + "parent-workspace-axoupdate-".to_owned() + ); + } + + #[cfg(unix)] + #[test] + fn test_can_execute_from_dir_detects_unusable_permissions() { + let parent = tempfile::tempdir().expect("parent tempdir"); + let no_exec = parent.path().join("noexec"); + fs::create_dir(&no_exec).expect("create test directory"); + fs::set_permissions(&no_exec, fs::Permissions::from_mode(0o600)).expect("chmod test dir"); + + assert!(!super::can_execute_from_dir(&no_exec)); + } +}