use std::path::{Path, PathBuf}; use crate::data::{JavaPackage, PackageArchive, java_executable_relative_path}; use crate::error::JavaResult; use oneclient_common::paths; use oneclient_events::EventBus; use oneclient_events::{GroupedProgressSession, TaskCategory, TaskPhase}; use oneclient_net::RequestClient; use oneclient_net::ResponseNotifyOptions; #[tracing::instrument(level = "debug", skip(net, events, progress))] pub async fn install_package( package: &JavaPackage, net: &RequestClient, events: &EventBus, progress: Option<&GroupedProgressSession>, ) -> JavaResult { let java_dir = paths::java_dir()?; polyio::create_dir_all(&java_dir).await?; let archive_path = java_dir.join(polyio::sanitize_path(&package.name)); let major = package.java_version.first().copied().unwrap_or(0); // 0 means unknown the real total comes from Content-Length once headers land let expected_size = package.size.unwrap_or(0); let child = progress.map(|session| { session.child( format!("{} {}", package.vendor, major), expected_size, TaskCategory::Java, ) }); let notify = match &child { Some(child) => ResponseNotifyOptions::grouped(child.clone()), None => { ResponseNotifyOptions::standalone(format!("Installing {} {}", package.vendor, major)) .done_label(format!("Installed {} {}", package.vendor, major)) } }; if package.checksum.is_none() { tracing::warn!( vendor = %package.vendor, major, "vendor published no usable checksum; installing runtime unverified" ); } oneclient_net::download_verified( net, events, &package.download_url, &archive_path, package.checksum.as_ref(), expected_size, Some(notify), ) .await?; let extract_root = java_dir.join(stem_without_archive(&package.name)); if let Some(child) = &child { child.set_phase(TaskPhase::Extracting); } //events.notify("Extracting package...").body(format!("{} {}", package.vendor, major)).send(); match package.archive { PackageArchive::Zip => polyio::extract_zip(&archive_path, &extract_root).await?, PackageArchive::TarGz => polyio::extract_tar_gz(&archive_path, &extract_root).await?, } // Walks the extract root and stats candidates, so it stays off the caller's // executor for the same reason the extraction above does let executable = { let extract_root = extract_root.clone(); let package = package.clone(); tokio::task::spawn_blocking(move || resolve_installed_executable(&extract_root, &package)) .await .map_err(|err| polyio::IOError::from(std::io::Error::other(err)))? }; let _ = polyio::remove_file(&archive_path).await; tracing::info!(vendor = %package.vendor, major, "installed Java runtime"); Ok(executable) } /// The directory this crate extracted for `executable`, or `None` when the /// runtime sits outside the launcher's java dir which is where a folder the /// user added themselves points #[tracing::instrument(level = "debug")] fn managed_install_root(executable: &Path) -> JavaResult> { let java_dir = paths::java_dir()?; let Ok(root) = polyio::canonicalize(&java_dir) else { return Ok(None); }; // Refuses to resolve once the executable is gone which is the safe answer // there is no way left to prove what the path pointed at let canon = match polyio::ensure_under(executable, [&root]) { Ok(Some(canon)) => canon, Ok(None) => return Ok(None), Err(err) => { tracing::debug!("could not resolve the java runtime path: {err}"); return Ok(None); } }; let Ok(relative) = canon.strip_prefix(&root) else { return Ok(None); }; match relative.components().next() { Some(std::path::Component::Normal(name)) => Ok(Some(root.join(name))), _ => Ok(None), } } /// Whether [`remove_installed_package`] would take this runtime's files with /// it so the UI can say up front what removing it costs #[must_use] pub fn is_launcher_managed(executable: &Path) -> bool { managed_install_root(executable).is_ok_and(|root| root.is_some()) } #[cfg(unix)] pub(crate) fn restore_executable_bits(executable: &Path) { use std::os::unix::fs::PermissionsExt; let Ok(executable) = polyio::canonicalize(executable) else { return; }; if !is_launcher_managed(&executable) { return; } let Some(home) = executable.parent().and_then(Path::parent) else { return; }; let jre = home.join("jre"); let entries = |dir: PathBuf| { std::fs::read_dir(dir) .into_iter() .flatten() .flatten() .map(|entry| entry.path()) }; let tools = entries(home.join("bin")).chain(entries(jre.join("bin"))); let helpers = ["jspawnhelper", "jexec"].map(|name| home.join("lib").join(name)); let jre_helpers = std::iter::once(jre.join("lib")) .chain(entries(jre.join("lib"))) .map(|dir| dir.join("jspawnhelper")); for path in tools.chain(helpers).chain(jre_helpers) { let Ok(metadata) = std::fs::metadata(&path) else { continue; }; let mode = metadata.permissions().mode(); if !metadata.is_file() || mode & 0o111 != 0 { continue; } match std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode | 0o111)) { Ok(()) => tracing::info!(path = %path.display(), "restored missing executable bit"), Err(err) => { tracing::warn!(path = %path.display(), "could not restore executable bit: {err}"); } } } } /// Only ever touches OneClient's own java dir a runtime the user added from /// their own folder is left on disk untouched #[tracing::instrument(level = "debug")] pub async fn remove_installed_package(executable: &Path) -> JavaResult { let Some(install_root) = managed_install_root(executable)? else { tracing::debug!("java runtime is not launcher-managed leaving its files alone"); return Ok(false); }; polyio::remove_dir_all(&install_root).await?; tracing::info!(path = %install_root.display(), "removed installed Java runtime files"); Ok(true) } #[tracing::instrument(level = "debug")] fn resolve_installed_executable(extract_root: &Path, package: &JavaPackage) -> PathBuf { let mut base_path = extract_root.to_path_buf(); if let Ok(entries) = std::fs::read_dir(extract_root) { let valid_dirs: Vec = entries .flatten() .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false)) .map(|e| e.path()) .collect(); if valid_dirs.len() == 1 { base_path = valid_dirs[0].clone(); } } #[cfg(not(target_os = "macos"))] { let _ = package; let subfolder_exec = base_path.join(java_executable_relative_path()); if subfolder_exec.exists() { subfolder_exec } else { extract_root.join(java_executable_relative_path()) } } #[cfg(target_os = "macos")] { use crate::vendors::JavaVendor; if !base_path.join("Contents").join("Home").exists() && let Ok(entries) = std::fs::read_dir(&base_path) && let Some(dir) = entries.flatten().find(|entry| { let file_name = entry.file_name(); let name = file_name.to_string_lossy(); (name.ends_with(".jre") || name.ends_with(".jdk") || name.contains("zulu")) && entry.path().join("Contents").join("Home").exists() }) { base_path = dir.path(); } if package.vendor == JavaVendor::Zulu && let Some(major) = package.java_version.first() { let zulu_bundle = base_path.join(format!("zulu-{major}.jre")); if zulu_bundle.join("Contents").join("Home").exists() { base_path = zulu_bundle; } } base_path.join(java_executable_relative_path()) } } fn stem_without_archive(name: &str) -> String { let path = std::path::Path::new(name); let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or(name); if stem.ends_with(".tar") { std::path::Path::new(stem) .file_stem() .and_then(|s| s.to_str()) .map(String::from) .unwrap_or_else(|| stem.to_string()) } else { stem.to_string() } } #[cfg(test)] mod tests { use super::*; use std::sync::atomic::{AtomicU32, Ordering}; /// Keeps the whole suite inside a temp tree the override is a `OnceLock` so /// the first caller wins and every case works under one java dir fn java_dir() -> PathBuf { static ONCE: std::sync::Once = std::sync::Once::new(); ONCE.call_once(|| { paths::set_launcher_dir( std::env::temp_dir().join(format!("oneclient-java-test-{}", std::process::id())), ); }); let dir = paths::java_dir().unwrap(); std::fs::create_dir_all(&dir).unwrap(); dir } fn install_runtime(root: &Path) -> (PathBuf, PathBuf) { static N: AtomicU32 = AtomicU32::new(0); let install = root.join(format!("zulu21-{}", N.fetch_add(1, Ordering::Relaxed))); let bin = install.join("zulu21.0.12").join("bin"); std::fs::create_dir_all(&bin).unwrap(); let executable = bin.join("java"); std::fs::write(&executable, b"").unwrap(); (install, executable) } #[test] fn resolves_the_extracted_directory_not_the_executable_parent() { let java_dir = java_dir(); let (install, executable) = install_runtime(&java_dir); let resolved = managed_install_root(&executable).unwrap().unwrap(); assert_eq!(resolved, polyio::canonicalize(&install).unwrap()); } #[tokio::test] async fn removing_a_managed_runtime_deletes_its_files() { let java_dir = java_dir(); let (install, executable) = install_runtime(&java_dir); assert!(remove_installed_package(&executable).await.unwrap()); assert!(!install.exists()); assert!(java_dir.exists(), "the java dir itself must survive"); } #[tokio::test] async fn a_runtime_outside_the_java_dir_keeps_its_files() { let java_dir = java_dir(); let elsewhere = java_dir.parent().unwrap().join("user-picked-jdk"); let (_, executable) = install_runtime(&elsewhere); assert_eq!(managed_install_root(&executable).unwrap(), None); assert!(!remove_installed_package(&executable).await.unwrap()); assert!( executable.exists(), "a folder the user added is not ours to delete" ); std::fs::remove_dir_all(&elsewhere).unwrap(); } #[test] fn a_traversal_back_out_of_the_java_dir_is_not_managed() { let java_dir = java_dir(); let elsewhere = java_dir.parent().unwrap().join("traversal-jdk"); let (_, executable) = install_runtime(&elsewhere); let sneaky = java_dir .join("..") .join("traversal-jdk") .join(executable.strip_prefix(&elsewhere).unwrap()); assert_eq!(managed_install_root(&sneaky).unwrap(), None); std::fs::remove_dir_all(&elsewhere).unwrap(); } #[test] fn the_managed_flag_matches_what_removal_would_actually_delete() { let java_dir = java_dir(); let (_, ours) = install_runtime(&java_dir); let elsewhere = java_dir.parent().unwrap().join("flagged-jdk"); let (_, theirs) = install_runtime(&elsewhere); assert!(is_launcher_managed(&ours)); assert!(!is_launcher_managed(&theirs)); std::fs::remove_dir_all(&elsewhere).unwrap(); } #[test] fn the_java_dir_itself_is_never_managed() { let java_dir = java_dir(); assert_eq!(managed_install_root(&java_dir).unwrap(), None); } }