diff --git a/Cargo.toml b/Cargo.toml index 881b57d5a7..51650be2d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -119,6 +119,7 @@ features = [ "Win32_Foundation", "Win32_Security", "Win32_Storage_FileSystem", + "Win32_System_Com", "Win32_System_Console", "Win32_System_Diagnostics_ToolHelp", "Win32_System_IO", @@ -131,6 +132,7 @@ features = [ "Win32_System_Threading", "Win32_System_WindowsProgramming", "Win32_UI", + "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", ] version = "0.61" diff --git a/src/cli/rustup_mode.rs b/src/cli/rustup_mode.rs index 34b3d87061..d66d99e397 100644 --- a/src/cli/rustup_mode.rs +++ b/src/cli/rustup_mode.rs @@ -389,7 +389,7 @@ enum ShowSubcmd { verbose: bool, }, - /// Display the computed value of RUSTUP_HOME + /// Display resolved Rustup home directories Home, /// Show the default profile used for the `rustup install` command @@ -1272,14 +1272,27 @@ async fn show(cfg: &Cfg<'_>, verbose: bool) -> anyhow::Result { cfg.default_host_tuple()? )?; - // Print rustup home directory { let mut t = t.lock(); - writeln!( - t, - "{HEADER}rustup home: {HEADER:#}{}", - cfg.rustup_dir.display() - )?; + if cfg.process.use_category_home() { + writeln!(t, "{HEADER}rustup homes:{HEADER:#}")?; + for (name, home) in [ + ("config", &cfg.rustup_config_dir), + ("state", &cfg.rustup_state_dir), + ("data", &cfg.rustup_data_dir), + ("cache", &cfg.rustup_cache_dir), + ("bin", &cfg.process.rustup_bin_home()?), + ] { + writeln!(t, " {name}: {}", home.display())?; + } + } else { + // In legacy mode, all four category homes equal the resolved RUSTUP_HOME. + writeln!( + t, + "{HEADER}rustup home: {HEADER:#}{}", + cfg.rustup_data_dir.display() + )?; + } writeln!(t)?; } @@ -1427,7 +1440,20 @@ async fn show_active_toolchain(cfg: &Cfg<'_>, verbose: bool) -> anyhow::Result) -> anyhow::Result { - writeln!(cfg.process.stdout().lock(), "{}", cfg.rustup_dir.display())?; + let mut stdout = cfg.process.stdout().lock(); + if cfg.process.use_category_home() { + for (name, home) in [ + ("config", &cfg.rustup_config_dir), + ("state", &cfg.rustup_state_dir), + ("data", &cfg.rustup_data_dir), + ("cache", &cfg.rustup_cache_dir), + ] { + writeln!(stdout, "{name}: {}", home.display())?; + } + } else { + // In legacy mode, all four category homes equal the resolved RUSTUP_HOME. + writeln!(stdout, "{}", cfg.rustup_data_dir.display())?; + } Ok(ExitCode::SUCCESS) } diff --git a/src/cli/self_update.rs b/src/cli/self_update.rs index 621dd80afe..817184171c 100644 --- a/src/cli/self_update.rs +++ b/src/cli/self_update.rs @@ -8,14 +8,14 @@ //! //! During install (as `rustup-init`): //! -//! * copy the self exe to $CARGO_HOME/bin -//! * hardlink rustc, etc to *that* +//! * copy the self exe to the Rustup bin home +//! * hardlink rustc, etc. to *that* //! * update the PATH in a system-specific way //! * run the equivalent of `rustup default stable` //! //! During upgrade (`rustup self upgrade`): //! -//! * download rustup-init to $CARGO_HOME/bin/rustup-init +//! * download rustup-init to the Rustup bin home //! * run rustup-init with appropriate flags to indicate //! this is a self-upgrade //! * rustup-init copies bins and hardlinks into place. On windows @@ -23,7 +23,7 @@ //! //! During uninstall (`rustup self uninstall`): //! -//! * Delete `$RUSTUP_HOME`. +//! * Delete all resolved Rustup homes. //! * Delete all entries in `$CARGO_HOME` except `bin`. //! * Delete rustup tool links and binary from `$CARGO_HOME/bin`. //! * Delete `$CARGO_HOME/bin` if it is empty after uninstall. @@ -192,17 +192,17 @@ impl InstallOpts<'_> { return Ok(ExitCode::FAILURE); } - let cargo_home = canonical_cargo_home(process)?; + let rustup_bin_home = canonical_rustup_bin_home(process)?; #[cfg(windows)] - let cargo_home = cargo_home.replace('\\', r"\\"); + let rustup_bin_home = rustup_bin_home.replace('\\', r"\\"); #[cfg(windows)] let msg = if no_modify_path { format!( post_install_msg_win_no_modify_path!(), - cargo_home = cargo_home + rustup_bin_home = rustup_bin_home ) } else { - format!(post_install_msg_win!(), cargo_home = cargo_home) + format!(post_install_msg_win!(), rustup_bin_home = rustup_bin_home) }; #[cfg(not(windows))] let source_env_lines = shell::build_source_env_lines(process); @@ -210,13 +210,13 @@ impl InstallOpts<'_> { let msg = if no_modify_path { format!( post_install_msg_unix_no_modify_path!(), - cargo_home = cargo_home, + rustup_bin_home = rustup_bin_home, source_env_lines = source_env_lines, ) } else { format!( post_install_msg_unix!(), - cargo_home = cargo_home, + rustup_bin_home = rustup_bin_home, source_env_lines = source_env_lines, ) }; @@ -255,16 +255,6 @@ impl InstallOpts<'_> { #[cfg(windows)] add_uninstall_registry_entry(process)?; - // If RUSTUP_HOME is not set, make sure it exists - if process.var_os("RUSTUP_HOME").is_none() { - let home = process - .home_dir() - .map(|p| p.join(".rustup")) - .ok_or_else(|| anyhow::anyhow!("could not find home dir to put .rustup in"))?; - - fs::create_dir_all(home).context("unable to create ~/.rustup")?; - } - let mut cfg = Cfg::from_env(current_dir, quiet, false, process)?; let (components, targets) = (self.components, self.targets); @@ -592,8 +582,32 @@ fn update_root(process: &Process) -> String { .unwrap_or_else(|_| String::from(DEFAULT_UPDATE_ROOT)) } -/// `CARGO_HOME` suitable for display, possibly with $HOME -/// substituted for the directory prefix +/// Rustup's binary installation directory suitable for display, possibly with +/// the home environment variable substituted for the directory prefix. +fn canonical_rustup_bin_home(process: &Process) -> anyhow::Result> { + let path = process.rustup_bin_home()?; + let Some(home) = process.home_dir() else { + return Ok(path.to_string_lossy().into_owned().into()); + }; + let Ok(relative) = path.strip_prefix(home) else { + return Ok(path.to_string_lossy().into_owned().into()); + }; + let Some(relative) = relative.to_str() else { + return Ok(path.to_string_lossy().into_owned().into()); + }; + let home = cfg_select! { + windows => r"%USERPROFILE%", + _ => "$HOME", + }; + Ok(if relative.is_empty() { + home.into() + } else { + format!("{home}{MAIN_SEPARATOR}{relative}").into() + }) +} + +/// `CARGO_HOME` suitable for display, possibly with $HOME substituted for the +/// directory prefix. fn canonical_cargo_home(process: &Process) -> anyhow::Result> { let path = process.cargo_home()?; @@ -620,8 +634,10 @@ fn rustc_or_cargo_exists_in_path(process: &Process) -> anyhow::Result<()> { .any(|c| c == Component::Normal(".cargo".as_ref())) } + let rustup_bin_home = process.rustup_bin_home()?; if let Some(paths) = process.var_os("PATH") { - let paths = env::split_paths(&paths).filter(ignore_paths); + let paths = + env::split_paths(&paths).filter(|path| ignore_paths(path) && path != &rustup_bin_home); for path in paths { let rustc = path.join(format!("rustc{EXE_SUFFIX}")); @@ -663,8 +679,7 @@ fn check_existence_of_rustc_or_cargo_in_path( } fn check_existence_of_settings_file(process: &Process) -> anyhow::Result<()> { - let rustup_dir = process.rustup_home()?; - let settings_file = SettingsFile::new(rustup_dir.join("settings.toml")); + let settings_file = SettingsFile::new(process.home_dirs()?.config.join("settings.toml")); if !utils::path_exists(&settings_file.path) { return Ok(()); } @@ -686,9 +701,37 @@ fn check_existence_of_settings_file(process: &Process) -> anyhow::Result<()> { } fn pre_install_msg(no_modify_path: bool, process: &Process) -> anyhow::Result { - let cargo_home = process.cargo_home()?; - let cargo_home_bin = cargo_home.join("bin"); - let rustup_home = home::rustup_home()?; + let rustup_bin_home = process.rustup_bin_home()?; + let home_dirs = process.home_dirs()?; + let rustup_home_message = if !process.use_category_home() { + // In legacy mode, all four category homes equal the resolved RUSTUP_HOME. + format!( + concat!( + "Rustup metadata and toolchains will be installed into the Rustup\n", + "home directory, located at:\n\n", + " {}\n\n", + "This can be modified with the RUSTUP_HOME environment variable." + ), + home_dirs.data.display() + ) + } else { + format!( + concat!( + "Rustup will use these directories:\n\n", + " config: {}\n", + " state: {}\n", + " data: {}\n", + " cache: {}\n\n", + "They can be modified individually with\n", + "RUSTUP_CONFIG_HOME, RUSTUP_STATE_HOME, RUSTUP_DATA_HOME, and\n", + "RUSTUP_CACHE_HOME." + ), + home_dirs.config.display(), + home_dirs.state.display(), + home_dirs.data.display(), + home_dirs.cache.display(), + ) + }; if !no_modify_path { // Brittle code warning: some duplication in unix::do_add_to_path @@ -702,26 +745,23 @@ fn pre_install_msg(no_modify_path: bool, process: &Process) -> anyhow::Result anyhow::Result<()> { - let bin_path = process.cargo_home()?.join("bin"); + let bin_path = process.rustup_bin_home()?; let this_exe_path = utils::current_exe()?; let rustup_path = bin_path.join(format!("rustup{EXE_SUFFIX}")); @@ -800,7 +840,7 @@ pub(crate) fn install_proxies(process: &Process) -> anyhow::Result<()> { } fn install_proxies_with_opts(process: &Process, force_hard_links: bool) -> anyhow::Result<()> { - let bin_path = process.cargo_home()?.join("bin"); + let bin_path = process.rustup_bin_home()?; let rustup_path = bin_path.join(format!("rustup{EXE_SUFFIX}")); let rustup = Handle::from_path(&rustup_path)?; @@ -906,7 +946,7 @@ fn check_proxy_sanity( components: &[&str], desc: &ToolchainDesc, ) -> anyhow::Result<()> { - let bin_path = process.cargo_home()?.join("bin"); + let bin_path = process.rustup_bin_home()?; // Sometimes linking a proxy produces an unpredictable result, where the proxy // is in place, but manages to not call rustup correctly. One way to make sure we @@ -930,7 +970,7 @@ fn check_proxy_sanity( /// Uninstall process: /// 1. Remove all installed toolchains. -/// 2. Remove rustup home. +/// 2. Remove all resolved Rustup homes. /// 3. Remove all entries in `$CARGO_HOME` except `bin`. /// 4. Remove rustup tool links and binary. /// 5. Try to remove $CARGO_HOME/bin directory if it's empty. @@ -949,8 +989,14 @@ pub(crate) fn uninstall( let process = cfg.process; let cargo_home = process.cargo_home()?; - - if !cargo_home.join(format!("bin/rustup{EXE_SUFFIX}")).exists() { + let legacy_bin = cargo_home.join("bin"); + let category_bin = process.rustup_bin_home()?; + let rustup_exe = format!("rustup{EXE_SUFFIX}"); + let legacy_rustup = legacy_bin.join(&rustup_exe); + let category_rustup = category_bin.join(rustup_exe); + let rustup_is_self_installed = legacy_rustup.try_exists()? + || (category_bin != legacy_bin && category_rustup.try_exists()?); + if !rustup_is_self_installed { return Err(CliError::NotSelfInstalled { p: cargo_home }.into()); } @@ -971,6 +1017,15 @@ pub(crate) fn uninstall( } } + #[cfg(unix)] + if process.use_category_home() && !no_modify_path { + let config_home = &cfg.rustup_config_dir; + do_remove_from_path(process, config_home)?; + if config_home != &cargo_home { + do_remove_from_path(process, &cargo_home)?; + } + } + info!("removing toolchains"); for toolchain in cfg.list_toolchains(true)? { Toolchain::ensure_removed(cfg, toolchain.into())?; @@ -978,21 +1033,30 @@ pub(crate) fn uninstall( info!("removing rustup home"); - // Delete RUSTUP_HOME - let rustup_dir = home::rustup_home()?; - if rustup_dir.exists() { - utils::remove_dir("rustup_home", &rustup_dir)?; + // Delete the legacy Rustup home and all resolved category homes. + let legacy_home = process.rustup_home()?; + + for (name, rustup_dir) in [ + ("rustup home", &legacy_home), + ("rustup cache home", &cfg.rustup_cache_dir), + ("rustup config home", &cfg.rustup_config_dir), + ("rustup data home", &cfg.rustup_data_dir), + ("rustup state home", &cfg.rustup_state_dir), + ] { + if rustup_dir.try_exists()? { + utils::remove_dir(name, rustup_dir)?; + } } // Delete rustup. #[cfg(unix)] - clean_cargo_home(no_modify_path, process)?; + clean_cargo_home(no_modify_path, process, &cargo_home, &category_bin)?; // NOTE: On windows, this is tricky because this is *probably* // the running executable and on Windows can't be unlinked until // the process exits. // see: windows::{complete_windows_uninstall,spawn_uninstall_gc} #[cfg(windows)] - windows::spawn_uninstall_gc(no_modify_path, process)?; + windows::spawn_uninstall_gc(no_modify_path)?; info!("rustup is uninstalled"); @@ -1000,77 +1064,77 @@ pub(crate) fn uninstall( } /// Remove rustup-owned cargo-home state. -/// This removes non-`bin` entries in `$CARGO_HOME`, removes rustup tool links and executable from -/// `$CARGO_HOME/bin`, then removes `$CARGO_HOME/bin` and `$CARGO_HOME` only if they are empty. +/// This removes non-`bin` entries in `$CARGO_HOME`, removes rustup-owned binaries from +/// both legacy and resolved bin directories, then removes directories only if they are empty. /// Nonempty directories are left in place. -fn clean_cargo_home(no_modify_path: bool, process: &Process) -> anyhow::Result<()> { - let cargo_home = process.cargo_home()?; - let cargo_bin = cargo_home.join("bin"); +fn clean_cargo_home( + no_modify_path: bool, + process: &Process, + cargo_home: &Path, + category_bin: &Path, +) -> anyhow::Result<()> { + let legacy_bin = cargo_home.join("bin"); info!("removing cargo home"); - // Delete everything in CARGO_HOME except the bin directory first. - let diriter = fs::read_dir(&cargo_home).map_err(|e| CliError::ReadDirError { - p: cargo_home.clone(), - source: e, - })?; - for dirent in diriter { - let dirent = dirent.map_err(|e| CliError::ReadDirError { - p: cargo_home.clone(), - source: e, - })?; - if dirent.file_name().to_str() != Some("bin") { - if dirent.path().is_dir() { - utils::remove_dir("cargo_home", &dirent.path())?; - } else { - utils::remove_file("cargo_home", &dirent.path())?; + // Delete everything in CARGO_HOME except the legacy bin directory and any + // subtree containing the resolved category bin. + match fs::read_dir(cargo_home) { + Ok(diriter) => { + for dirent in diriter { + let dirent = dirent.map_err(|source| CliError::ReadDirError { + p: cargo_home.to_owned(), + source, + })?; + if dirent.file_name().to_str() == Some("bin") { + continue; + } + let path = dirent.path(); + if category_bin == cargo_home || category_bin.starts_with(&path) { + continue; + } + + if path.is_dir() { + utils::remove_dir("cargo_home", &path)?; + } else { + utils::remove_file("cargo_home", &path)?; + } + } + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(source) => { + return Err(CliError::ReadDirError { + p: cargo_home.to_owned(), + source, } + .into()); } } info!("removing rustup tool links and binary"); - let rustup_path = cargo_bin.join(format!("rustup{EXE_SUFFIX}")); - - let proxy_paths = TOOLS - .iter() - .chain(DUP_TOOLS.iter()) - .map(|tool| cargo_bin.join(format!("{tool}{EXE_SUFFIX}"))); - - for proxy_path in proxy_paths { - if is_same_file(&proxy_path, &rustup_path).unwrap_or(false) { - utils::remove_file("rustup tool proxy", &proxy_path)?; + for bin in std::iter::once(legacy_bin.as_path()) + .chain((category_bin != legacy_bin).then_some(category_bin)) + { + let bin_removed = clean_rustup_binaries(bin)?; + if bin_removed && !no_modify_path { + #[cfg(windows)] + do_remove_from_path(process, bin)?; + #[cfg(unix)] + if !process.use_category_home() && bin == legacy_bin { + do_remove_from_path(process, cargo_home)?; + } } } - utils::remove_file("rustup_bin", &rustup_path)?; - #[cfg(windows)] remove_uninstall_registry_entry(process)?; - let cargo_bin_display = cargo_bin.display(); - info!("removing empty cargo bin directory `{cargo_bin_display}`"); - - match fs::remove_dir(&cargo_bin) { - Err(e) if e.kind() == io::ErrorKind::DirectoryNotEmpty => { - warn!("keeping non-empty cargo bin directory `{cargo_bin_display}`") - } - Err(e) => { - return Err(e).with_context(|| { - format!("failed to remove cargo bin directory `{cargo_bin_display}`") - }); - } - Ok(()) if !no_modify_path => { - info!("removing cargo bin directory `{cargo_bin_display}` from $PATH"); - do_remove_from_path(process)?; - } - Ok(()) => {} - } - let cargo_home_display = cargo_home.display(); info!("removing empty cargo home directory `{cargo_home_display}`"); - match fs::remove_dir(&cargo_home) { + match fs::remove_dir(cargo_home) { + Err(e) if e.kind() == io::ErrorKind::NotFound => {} Err(e) if e.kind() == io::ErrorKind::DirectoryNotEmpty => { warn!("keeping non-empty cargo home directory `{cargo_home_display}`"); } @@ -1085,6 +1149,42 @@ fn clean_cargo_home(no_modify_path: bool, process: &Process) -> anyhow::Result<( Ok(()) } +/// Remove rustup-owned binaries from a bin directory. +/// +/// Returns whether the directory was removed after becoming empty. +fn clean_rustup_binaries(bin_dir: &Path) -> anyhow::Result { + let rustup_path = bin_dir.join(format!("rustup{EXE_SUFFIX}")); + if !rustup_path.try_exists()? { + return Ok(false); + } + + let proxy_paths = TOOLS + .iter() + .chain(DUP_TOOLS.iter()) + .map(|tool| bin_dir.join(format!("{tool}{EXE_SUFFIX}"))); + + for proxy_path in proxy_paths { + if is_same_file(&proxy_path, &rustup_path).unwrap_or(false) { + utils::remove_file("rustup tool proxy", &proxy_path)?; + } + } + + utils::remove_file("rustup_bin", &rustup_path)?; + + let bin_dir_display = bin_dir.display(); + info!("removing empty cargo bin directory `{bin_dir_display}`"); + + match fs::remove_dir(bin_dir) { + Ok(()) => Ok(true), + Err(error) if error.kind() == io::ErrorKind::DirectoryNotEmpty => { + warn!("keeping non-empty cargo bin directory `{bin_dir_display}`"); + Ok(false) + } + Err(error) => Err(error) + .with_context(|| format!("failed to remove cargo bin directory `{bin_dir_display}`")), + } +} + #[derive(Clone, Copy, Debug)] pub(crate) enum SelfUpdatePermission { HardFail, @@ -1122,8 +1222,7 @@ pub(crate) fn self_update_permitted(explicit: bool) -> anyhow::Result anyhow::Result) -> anyhow::Result { common::warn_if_host_is_emulated(cfg.process); @@ -1215,12 +1313,12 @@ fn parse_new_rustup_version(version: String) -> String { } pub(crate) async fn prepare_update(dl_cfg: &DownloadCfg<'_>) -> anyhow::Result> { - let cargo_home = dl_cfg.process.cargo_home()?; - let rustup_path = cargo_home.join(format!("bin{MAIN_SEPARATOR}rustup{EXE_SUFFIX}")); - let setup_path = cargo_home.join(format!("bin{MAIN_SEPARATOR}rustup-init{EXE_SUFFIX}")); + let bin_home = dl_cfg.process.rustup_bin_home()?; + let rustup_path = bin_home.join(format!("rustup{EXE_SUFFIX}")); + let setup_path = bin_home.join(format!("rustup-init{EXE_SUFFIX}")); if !rustup_path.exists() { - return Err(CliError::NotSelfInstalled { p: cargo_home }.into()); + return Err(CliError::NotSelfInstalled { p: bin_home }.into()); } if setup_path.exists() { @@ -1371,8 +1469,9 @@ pub(crate) async fn check_rustup_update(dl_cfg: &DownloadCfg<'_>) -> anyhow::Res #[tracing::instrument(level = "trace")] pub(crate) fn cleanup_self_updater(process: &Process) -> anyhow::Result<()> { - let cargo_home = process.cargo_home()?; - let setup = cargo_home.join(format!("bin/rustup-init{EXE_SUFFIX}")); + let setup = process + .rustup_bin_home()? + .join(format!("rustup-init{EXE_SUFFIX}")); if setup.exists() { utils::remove_file("setup", &setup)?; diff --git a/src/cli/self_update/env.fish b/src/cli/self_update/env.fish index b6549f504d..d53c1ae279 100644 --- a/src/cli/self_update/env.fish +++ b/src/cli/self_update/env.fish @@ -1,5 +1,5 @@ # rustup shell setup -if not contains "{cargo_bin}" $PATH +if not contains "{rustup_bin}" $PATH # Prepending path in case a system-installed rustc needs to be overridden - set -x PATH "{cargo_bin}" $PATH + set -x PATH "{rustup_bin}" $PATH end diff --git a/src/cli/self_update/env.nu b/src/cli/self_update/env.nu index 782e41e7c5..5a46d67ef9 100644 --- a/src/cli/self_update/env.nu +++ b/src/cli/self_update/env.nu @@ -1,2 +1,2 @@ use std/util "path add" -path add "{cargo_bin}" +path add "{rustup_bin}" diff --git a/src/cli/self_update/env.ps1 b/src/cli/self_update/env.ps1 index 6cc7b290ed..d15fbbc325 100644 --- a/src/cli/self_update/env.ps1 +++ b/src/cli/self_update/env.ps1 @@ -1,4 +1,4 @@ # rustup shell setup -if (-not ":${env:PATH}:".Contains(":{cargo_bin}:")) { - ${env:PATH} = "{cargo_bin}:${env:PATH}"; +if (-not ":${env:PATH}:".Contains(":{rustup_bin}:")) { + ${env:PATH} = "{rustup_bin}:${env:PATH}"; } diff --git a/src/cli/self_update/env.sh b/src/cli/self_update/env.sh index 7cc2b57a06..398744cbe8 100644 --- a/src/cli/self_update/env.sh +++ b/src/cli/self_update/env.sh @@ -2,10 +2,10 @@ # rustup shell setup # affix colons on either side of $PATH to simplify matching case ":${PATH}:" in - *:"{cargo_bin}":*) + *:"{rustup_bin}":*) ;; *) # Prepending path in case a system-installed rustc needs to be overridden - export PATH="{cargo_bin}:$PATH" + export PATH="{rustup_bin}:$PATH" ;; esac diff --git a/src/cli/self_update/env.tcsh b/src/cli/self_update/env.tcsh index bd89ed32ff..1679b3de82 100644 --- a/src/cli/self_update/env.tcsh +++ b/src/cli/self_update/env.tcsh @@ -1,8 +1,8 @@ # rustup environment for tcsh if ( $?PATH ) then - if ( "$PATH" !~ *{cargo_bin}* ) then - setenv PATH "{cargo_bin}:$PATH" + if ( "$PATH" !~ *{rustup_bin}* ) then + setenv PATH "{rustup_bin}:$PATH" endif else - setenv PATH "{cargo_bin}" + setenv PATH "{rustup_bin}" endif diff --git a/src/cli/self_update/env.xsh b/src/cli/self_update/env.xsh index 469274c80f..6839645a8f 100644 --- a/src/cli/self_update/env.xsh +++ b/src/cli/self_update/env.xsh @@ -1 +1 @@ -$PATH.append(_cargo_bin) if (_cargo_bin := '{cargo_bin}') not in $PATH else None +$PATH.append(_rustup_bin) if (_rustup_bin := '{rustup_bin}') not in $PATH else None diff --git a/src/cli/self_update/msg.rs b/src/cli/self_update/msg.rs index fecaafc695..d676081475 100644 --- a/src/cli/self_update/msg.rs +++ b/src/cli/self_update/msg.rs @@ -10,23 +10,15 @@ macro_rules! pre_install_msg_template { This will download and install the official compiler for the Rust programming language, and its package manager, Cargo. -Rustup metadata and toolchains will be installed into the Rustup -home directory, located at: - - {rustup_home} - -This can be modified with the RUSTUP_HOME environment variable. - -The Cargo home directory is located at: - - {cargo_home} - -This can be modified with the CARGO_HOME environment variable. +{rustup_home_message} The `cargo`, `rustc`, `rustup` and other commands will be added to -Cargo's bin directory, located at: +Rustup's bin directory, located at: + + {rustup_bin_home} - {cargo_home_bin} +This can be modified with CARGO_HOME, or overridden in category +home mode with RUSTUP_BIN_HOME. ", $platform_msg, @@ -77,12 +69,10 @@ macro_rules! post_install_msg_unix { To get started you may need to restart your current shell. This would reload your `PATH` environment variable to include -Cargo's bin directory ({cargo_home}/bin). +Rustup's bin directory ({rustup_bin_home}). -To configure your current shell, you need to source the -corresponding `env` file under {cargo_home}. - -Consider running the right command for your shell (note the leading DOT): +To configure your current shell, run the right command below +(note the leading DOT): {source_env_lines}" }; } @@ -95,7 +85,7 @@ macro_rules! post_install_msg_win { To get started you may need to restart your current shell. This would reload its `PATH` environment variable to include -Cargo's bin directory ({cargo_home}\\bin). +Rustup's bin directory ({rustup_bin_home}). " }; } @@ -105,13 +95,11 @@ macro_rules! post_install_msg_unix_no_modify_path { () => { r"# Rust is installed now. Great! -To get started you need Cargo's bin directory ({cargo_home}/bin) in your `PATH` -environment variable. This has not been done automatically. - -To configure your current shell, you need to source -the corresponding `env` file under {cargo_home}. +To get started you need Rustup's bin directory ({rustup_bin_home}) in your +`PATH` environment variable. This has not been done automatically. -Consider running the right command for your shell (note the leading DOT): +To configure your current shell, run the right command below +(note the leading DOT): {source_env_lines}" }; } @@ -121,8 +109,8 @@ macro_rules! post_install_msg_win_no_modify_path { () => { r"# Rust is installed now. Great! -To get started you need Cargo's bin directory ({cargo_home}\\bin) in your `PATH` -environment variable. This has not been done automatically. +To get started you need Rustup's bin directory ({rustup_bin_home}) in your +`PATH` environment variable. This has not been done automatically. " }; } diff --git a/src/cli/self_update/shell.rs b/src/cli/self_update/shell.rs index 4e3e062923..ca9a41c7bc 100644 --- a/src/cli/self_update/shell.rs +++ b/src/cli/self_update/shell.rs @@ -23,9 +23,9 @@ //! 1) using a shell script that updates PATH if the path is not in PATH //! 2) sourcing this script (`. /path/to/script`) in any appropriate rc file -use std::{borrow::Cow, path::PathBuf}; +use std::path::{Path, PathBuf}; -use anyhow::bail; +use anyhow::{Context, bail}; use super::utils; use crate::process::Process; @@ -38,22 +38,17 @@ pub(crate) struct ShellScript { name: &'static str, } -// TODO: Update into a bytestring. -fn cargo_home_str_with_home(home: &str, process: &Process) -> anyhow::Result> { - let path = process.cargo_home()?; - - let default_cargo_home = process - .home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".cargo"); - Ok(if default_cargo_home == path { - Cow::Owned(format!("{home}/.cargo")) +// Historical path spelling, used only when recognizing old shell commands. +pub(super) fn legacy_env_home<'a>( + env_home: &'a Path, + home_dir: Option<&Path>, + default_home: &'static str, +) -> anyhow::Result<&'a str> { + if home_dir.is_some_and(|home| env_home == home.join(".cargo")) { + Ok(default_home) } else { - match path.to_str() { - Some(p) => p.to_owned().into(), - None => bail!("Non-Unicode path!"), - } - }) + env_home.to_str().context("Non-Unicode path!") + } } // TODO: Tcsh (BSD) @@ -76,9 +71,12 @@ fn enumerate_shells() -> Vec { /// shells that are available on the current system. Shells sharing the same /// env file are grouped onto one line (e.g. sh/bash/zsh all use `env`). pub(crate) fn build_source_env_lines(process: &Process) -> String { + let Ok(env_home) = process.rustup_env_home() else { + return String::new(); + }; let mut groups = Vec::<(_, Vec<_>)>::new(); for shell in get_available_shells(process) { - let Ok(src) = shell.source_string(process) else { + let Ok(src) = shell.source_string(&env_home) else { continue; }; if let Some(names) = groups @@ -126,25 +124,31 @@ pub(crate) trait UnixShell { } } - fn cargo_home_str(&self, process: &Process) -> anyhow::Result> { - #[cfg(windows)] - let home = "%USERPROFILE%"; - #[cfg(not(windows))] - let home = "$HOME"; - cargo_home_str_with_home(home, process) + fn source_string(&self, env_home: &Path) -> anyhow::Result { + let env_home = env_home.to_str().context("Non-Unicode path!")?; + Ok(format!(r#". "{env_home}/env""#)) } - fn source_string(&self, process: &Process) -> anyhow::Result { - Ok(format!(r#". "{}/env""#, self.cargo_home_str(process)?)) + // Keep historical command text independent of the current formatter. + fn legacy_source_string( + &self, + env_home: &Path, + home_dir: Option<&Path>, + ) -> anyhow::Result { + let env_home = legacy_env_home(env_home, home_dir, "$HOME/.cargo")?; + Ok(format!(r#". "{env_home}/env""#)) } fn write_script(&self, script: &ShellScript, process: &Process) -> anyhow::Result<()> { - let home = process.cargo_home()?; - let cargo_bin = format!("{}/bin", self.cargo_home_str(process)?); - let env_name = home.join(script.name); - let env_file = script.content.replace("{cargo_bin}", &cargo_bin); - utils::write_file(script.name, &env_name, &env_file)?; - Ok(()) + let env_home = process.rustup_env_home()?; + let bin_home = process.rustup_bin_home()?; + let rustup_bin = bin_home.to_str().context("Non-Unicode path!")?; + utils::ensure_dir_exists("env file home", &env_home)?; + utils::write_file( + script.name, + &env_home.join(script.name), + &script.content.replace("{rustup_bin}", rustup_bin), + ) } } @@ -303,11 +307,18 @@ impl UnixShell for Fish { } } - fn source_string(&self, process: &Process) -> anyhow::Result { - Ok(format!( - r#"source "{}/env.fish""#, - self.cargo_home_str(process)? - )) + fn source_string(&self, env_home: &Path) -> anyhow::Result { + let env_home = env_home.to_str().context("Non-Unicode path!")?; + Ok(format!(r#"source "{env_home}/env.fish""#)) + } + + fn legacy_source_string( + &self, + env_home: &Path, + home_dir: Option<&Path>, + ) -> anyhow::Result { + let env_home = legacy_env_home(env_home, home_dir, "$HOME/.cargo")?; + Ok(format!(r#"source "{env_home}/env.fish""#)) } } @@ -355,15 +366,18 @@ impl UnixShell for Nu { } } - fn source_string(&self, process: &Process) -> anyhow::Result { - Ok(format!( - r#"source "{}/env.nu""#, - self.cargo_home_str(process)? - )) + fn source_string(&self, env_home: &Path) -> anyhow::Result { + let env_home = env_home.to_str().context("Non-Unicode path!")?; + Ok(format!(r#"source "{env_home}/env.nu""#)) } - fn cargo_home_str(&self, process: &Process) -> anyhow::Result> { - cargo_home_str_with_home("~", process) + fn legacy_source_string( + &self, + env_home: &Path, + home_dir: Option<&Path>, + ) -> anyhow::Result { + let env_home = legacy_env_home(env_home, home_dir, "~/.cargo")?; + Ok(format!(r#"source "{env_home}/env.nu""#)) } } @@ -412,11 +426,18 @@ impl UnixShell for Tcsh { } } - fn source_string(&self, process: &Process) -> anyhow::Result { - Ok(format!( - r#"source "{}/env.tcsh""#, - self.cargo_home_str(process)? - )) + fn source_string(&self, env_home: &Path) -> anyhow::Result { + let env_home = env_home.to_str().context("Non-Unicode path!")?; + Ok(format!(r#"source "{env_home}/env.tcsh""#)) + } + + fn legacy_source_string( + &self, + env_home: &Path, + home_dir: Option<&Path>, + ) -> anyhow::Result { + let env_home = legacy_env_home(env_home, home_dir, "$HOME/.cargo")?; + Ok(format!(r#"source "{env_home}/env.tcsh""#)) } } @@ -495,8 +516,18 @@ impl UnixShell for Pwsh { } } - fn source_string(&self, process: &Process) -> anyhow::Result { - Ok(format!(r#". "{}/env.ps1""#, self.cargo_home_str(process)?)) + fn source_string(&self, env_home: &Path) -> anyhow::Result { + let env_home = env_home.to_str().context("Non-Unicode path!")?; + Ok(format!(r#". "{env_home}/env.ps1""#)) + } + + fn legacy_source_string( + &self, + env_home: &Path, + home_dir: Option<&Path>, + ) -> anyhow::Result { + let env_home = legacy_env_home(env_home, home_dir, "$HOME/.cargo")?; + Ok(format!(r#". "{env_home}/env.ps1""#)) } } @@ -547,15 +578,18 @@ impl UnixShell for Xonsh { } } - fn source_string(&self, process: &Process) -> anyhow::Result { - Ok(format!( - r#"source "{}/env.xsh""#, - self.cargo_home_str(process)? - )) + fn source_string(&self, env_home: &Path) -> anyhow::Result { + let env_home = env_home.to_str().context("Non-Unicode path!")?; + Ok(format!(r#"source "{env_home}/env.xsh""#)) } - fn cargo_home_str(&self, process: &Process) -> anyhow::Result> { - cargo_home_str_with_home("$HOME", process) + fn legacy_source_string( + &self, + env_home: &Path, + home_dir: Option<&Path>, + ) -> anyhow::Result { + let env_home = legacy_env_home(env_home, home_dir, "$HOME/.cargo")?; + Ok(format!(r#"source "{env_home}/env.xsh""#)) } } @@ -570,3 +604,59 @@ pub(crate) fn legacy_paths(process: &Process) -> impl Iterator + profiles.chain(zprofiles) } + +#[cfg(test)] +mod tests { + use super::{Fish, Nu, Path, Pwsh, Tcsh, UnixShell, Xonsh}; + + #[test] + fn source_strings_keep_current_and_legacy_formats() { + // Freeze both current absolute commands and historical HOME abbreviations. + let cases: [(&dyn UnixShell, &str, &str); 5] = [ + ( + &Fish, + r#"source "/home/user/.cargo/env.fish""#, + r#"source "$HOME/.cargo/env.fish""#, + ), + ( + &Nu, + r#"source "/home/user/.cargo/env.nu""#, + r#"source "~/.cargo/env.nu""#, + ), + ( + &Tcsh, + r#"source "/home/user/.cargo/env.tcsh""#, + r#"source "$HOME/.cargo/env.tcsh""#, + ), + ( + &Pwsh, + r#". "/home/user/.cargo/env.ps1""#, + r#". "$HOME/.cargo/env.ps1""#, + ), + ( + &Xonsh, + r#"source "/home/user/.cargo/env.xsh""#, + r#"source "$HOME/.cargo/env.xsh""#, + ), + ]; + for (shell, current, legacy) in cases { + assert_eq!( + shell.source_string(Path::new("/home/user/.cargo")).unwrap(), + current, + "{}", + shell.name() + ); + assert_eq!( + shell + .legacy_source_string( + Path::new("/home/user/.cargo"), + Some(Path::new("/home/user")), + ) + .unwrap(), + legacy, + "{}", + shell.name() + ); + } + } +} diff --git a/src/cli/self_update/unix.rs b/src/cli/self_update/unix.rs index 5da3d78ba0..31eb2b4257 100644 --- a/src/cli/self_update/unix.rs +++ b/src/cli/self_update/unix.rs @@ -6,10 +6,7 @@ use std::{ use anyhow::{Context, bail}; use tracing::{error, warn}; -use super::{ - install_bins, - shell::{self, Posix, UnixShell}, -}; +use super::{install_bins, shell}; use crate::{process::Process, utils}; // If the user is trying to install with sudo, on some systems this will @@ -53,20 +50,27 @@ pub(crate) fn do_anti_sudo_check( Ok(utils::ExitCode(0)) } -pub(crate) fn do_remove_from_path(process: &Process) -> anyhow::Result<()> { +pub(crate) fn do_remove_from_path(process: &Process, env_home: &Path) -> anyhow::Result<()> { + let home_dir = process.home_dir(); for sh in shell::get_available_shells(process) { - let source_bytes = format!("{}\n", sh.source_string(process)?).into_bytes(); + let commands = [ + sh.source_string(env_home)?, + sh.legacy_source_string(env_home, home_dir.as_deref())?, + ]; // Check more files for cleanup than normally are updated. for rc in sh.rcfiles(process).iter().filter(|rc| rc.is_file()) { let file = utils::read_file("rcfile", rc)?; - let file_bytes = file.into_bytes(); // FIXME: This is whitespace sensitive where it should not be. - if let Some(idx) = find_exact_line(&file_bytes, &source_bytes) { - // Here we rewrite the file without the offending line. - let mut new_bytes = file_bytes[..idx].to_vec(); - new_bytes.extend(&file_bytes[idx + source_bytes.len()..]); - let new_file = String::from_utf8(new_bytes).unwrap(); + let new_file: String = file + .split_inclusive('\n') + .filter(|line| { + !commands + .iter() + .any(|cmd| line.trim_end_matches(['\r', '\n']) == cmd) + }) + .collect(); + if new_file != file { utils::write_file("rcfile", rc, &new_file)?; } } @@ -78,13 +82,22 @@ pub(crate) fn do_remove_from_path(process: &Process) -> anyhow::Result<()> { } pub(crate) fn do_add_to_path(process: &Process) -> anyhow::Result<()> { + let env_home = process.rustup_env_home()?; + let home_dir = process.home_dir(); for sh in shell::get_available_shells(process) { - let source_cmd = sh.source_string(process)?; + let source_cmd = sh.source_string(&env_home)?; + let legacy_cmd = sh.legacy_source_string(&env_home, home_dir.as_deref())?; let source_cmd_with_newline = format!("\n{source_cmd}"); for rc in sh.update_rcs(process) { let cmd_to_write = match utils::read_file("rcfile", &rc) { - Ok(contents) if contents.contains(&source_cmd) => continue, + Ok(contents) + if contents + .lines() + .any(|line| line == source_cmd || line == legacy_cmd) => + { + continue; + } Ok(contents) if !contents.ends_with('\n') => &source_cmd_with_newline, _ => &source_cmd, }; @@ -136,9 +149,8 @@ pub(crate) fn run_update(setup_path: &Path, _process: &Process) -> anyhow::Resul Ok(utils::ExitCode(0)) } -/// This function is as the final step of a self-upgrade. It replaces -/// `$CARGO_HOME/bin/rustup` with the running exe, and updates the -/// links to it. +/// This function is the final step of a self-upgrade. It replaces Rustup in +/// the Rustup bin home and updates the proxy links. pub(crate) fn self_replace(process: &Process) -> anyhow::Result { install_bins(process)?; @@ -176,20 +188,14 @@ fn remove_legacy_paths(process: &Process) -> anyhow::Result<()> { // Before the work to support more kinds of shells, which was released in // version 1.23.0 of Rustup, we always inserted this line instead, which is // now considered legacy - remove_legacy_source_command( - format!( - "export PATH=\"{}/bin:$PATH\"\n", - Posix.cargo_home_str(process)? - ), - process, - )?; + let cargo_home = process.cargo_home()?; + let cargo_home = + shell::legacy_env_home(&cargo_home, process.home_dir().as_deref(), "$HOME/.cargo")?; + remove_legacy_source_command(format!("export PATH=\"{cargo_home}/bin:$PATH\"\n"), process)?; // Unfortunately in 1.23, we accidentally used `source` rather than `.` // which, while widely supported, isn't actually POSIX, so we also // clean that up here. This issue was filed as #2623. - remove_legacy_source_command( - format!("source \"{}/env\"\n", Posix.cargo_home_str(process)?), - process, - )?; + remove_legacy_source_command(format!("source \"{cargo_home}/env\"\n"), process)?; Ok(()) } diff --git a/src/cli/self_update/windows.rs b/src/cli/self_update/windows.rs index 28fcdcfdac..51fc749dbc 100644 --- a/src/cli/self_update/windows.rs +++ b/src/cli/self_update/windows.rs @@ -1,10 +1,9 @@ use std::{ borrow::Cow, - env::{consts::EXE_SUFFIX, split_paths}, + env::split_paths, ffi::{OsStr, OsString}, fmt, io::Write, - os::windows::ffi::OsStrExt, path::Path, process::Command, }; @@ -360,25 +359,29 @@ fn has_windows_sdk_libs(process: &Process) -> bool { pub fn complete_windows_uninstall(process: &Process) -> anyhow::Result { use std::process::Stdio; - wait_for_parent()?; - - let no_modify_path = process.var_os(GC_MODIFY_PATH).as_deref() != Some(OsStr::new("1")); + let uninstall = wait_for_parent().and_then(|()| { + let no_modify_path = process.var_os(GC_MODIFY_PATH).as_deref() != Some(OsStr::new("1")); - // Now that the parent has exited there are hopefully no more files open in CARGO_HOME. - super::clean_cargo_home(no_modify_path, process)?; + // Now that the parent has exited there are hopefully no more files open in CARGO_HOME. + let cargo_home = process.cargo_home()?; + let category_bin = process.rustup_bin_home()?; + super::clean_cargo_home(no_modify_path, process, &cargo_home, &category_bin) + }); // Now, run a *system* binary to inherit the DELETE_ON_CLOSE // handle to *this* process, then exit. The OS will delete the gc - // exe when it exits. - let rm_gc_exe = OsStr::new("net"); - - Command::new(rm_gc_exe) - .stdin(Stdio::null()) + // exe when it exits. Do this even if uninstalling failed. + // Leave stdin inherited so the standard library passes GC's delete-on-close + // handle to the cleanup child without raw handle APIs. + let cleanup = Command::new("net") .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() - .context(CliError::WindowsUninstallMadness)?; + .context(CliError::WindowsUninstallMadness); + // Preserve the original uninstall error if starting cleanup also failed. + uninstall?; + cleanup?; Ok(utils::ExitCode(0)) } @@ -458,7 +461,8 @@ pub(crate) fn wait_for_parent() -> anyhow::Result<()> { } pub(crate) fn do_add_to_path(process: &Process) -> anyhow::Result<()> { - let new_path = _with_path_cargo_home_bin(_add_to_path, process)?; + let rustup_bin_home = process.rustup_bin_home()?; + let new_path = _with_path(_add_to_path, &rustup_bin_home, process)?; _apply_new_path(new_path, process) } @@ -561,18 +565,16 @@ fn _remove_from_path(old_path: HSTRING, path_str: HSTRING) -> Option { const PATH_SEPARATOR: u16 = b';' as u16; -fn _with_path_cargo_home_bin(f: F, process: &Process) -> anyhow::Result> +fn _with_path(f: F, path: &Path, process: &Process) -> anyhow::Result> where F: FnOnce(HSTRING, HSTRING) -> Option, { let windows_path = get_windows_path_var(process)?; - let mut path_str = process.cargo_home()?; - path_str.push("bin"); - Ok(windows_path.and_then(|old_path| f(old_path, HSTRING::from(path_str.as_path())))) + Ok(windows_path.and_then(|old_path| f(old_path, HSTRING::from(path)))) } -pub(crate) fn do_remove_from_path(process: &Process) -> anyhow::Result<()> { - let new_path = _with_path_cargo_home_bin(_remove_from_path, process)?; +pub(crate) fn do_remove_from_path(process: &Process, bin_home: &Path) -> anyhow::Result<()> { + let new_path = _with_path(_remove_from_path, bin_home, process)?; _apply_new_path(new_path, process) } @@ -635,8 +637,7 @@ pub(crate) fn add_uninstall_registry_entry(process: &Process) -> anyhow::Result< } } - let mut path = process.cargo_home()?; - path.push("bin\\rustup.exe"); + let path = process.rustup_bin_home()?.join("rustup.exe"); let mut uninstall_cmd = OsString::from("\""); uninstall_cmd.push(path); uninstall_cmd.push("\" self uninstall"); @@ -686,19 +687,18 @@ pub(crate) fn self_replace(process: &Process) -> anyhow::Result // while they are open, like when they are running. // // Here's what we're going to do: -// - Copy rustup.exe to a temporary file in -// CARGO_HOME/../rustup-gc-$random.exe. +// - Copy the running rustup.exe to a temporary file in +// the system temporary directory as rustup-gc-$random.exe. // - Open the gc exe with the FILE_FLAG_DELETE_ON_CLOSE and // FILE_SHARE_DELETE flags. This is going to be the last // file to remove, and the OS is going to do it for us. -// This file is opened as inheritable so that subsequent -// processes created with the option to inherit handles -// will also keep them open. +// Pass this handle as stdin so the standard library manages inheritance. +// GC does not read stdin; it uses it only to carry the deletion handle. // - Run the gc exe, which waits for the original rustup.exe // process to close, then deletes CARGO_HOME. This process // has inherited a FILE_FLAG_DELETE_ON_CLOSE handle to itself. -// - Finally, spawn yet another system binary with the inherit handles -// flag, so *it* inherits the FILE_FLAG_DELETE_ON_CLOSE handle to +// - Finally, spawn yet another system binary inheriting stdin, +// so *it* inherits the FILE_FLAG_DELETE_ON_CLOSE handle to // the gc exe. If the gc exe exits before the system exe then at // last it will be deleted when the handle closes. // @@ -710,67 +710,55 @@ pub(crate) fn self_replace(process: &Process) -> anyhow::Result // // .. augmented with this SO answer // https://stackoverflow.com/questions/10319526/understanding-a-self-deleting-program-in-c -pub(crate) fn spawn_uninstall_gc(no_modify_path: bool, process: &Process) -> anyhow::Result<()> { - use std::{io, ptr, thread, time::Duration}; - - use windows_sys::Win32::{ - Foundation::{CloseHandle, GENERIC_READ, INVALID_HANDLE_VALUE}, - Security::SECURITY_ATTRIBUTES, - Storage::FileSystem::{ - CreateFileW, FILE_FLAG_DELETE_ON_CLOSE, FILE_SHARE_DELETE, FILE_SHARE_READ, - OPEN_EXISTING, - }, +pub(crate) fn spawn_uninstall_gc(no_modify_path: bool) -> anyhow::Result<()> { + use std::{ + fs::{File, OpenOptions}, + io, + os::windows::fs::OpenOptionsExt, + thread, + time::Duration, }; - // CARGO_HOME, hopefully empty except for bin/rustup.exe - let cargo_home = process.cargo_home()?; - // The rustup.exe bin - let rustup_path = cargo_home.join(format!("bin/rustup{EXE_SUFFIX}")); - - // The directory containing CARGO_HOME - let work_path = cargo_home - .parent() - .expect("CARGO_HOME doesn't have a parent?"); - - // Generate a unique name for the files we're about to move out - // of CARGO_HOME. - let numbah: u32 = rand::random(); - let gc_exe = work_path.join(format!("rustup-gc-{numbah:x}.exe")); - // Copy rustup (probably this process's exe) to the gc exe - utils::copy_file_symlink_to_source(&rustup_path, &gc_exe)?; - let gc_exe_win: Vec<_> = gc_exe.as_os_str().encode_wide().chain(Some(0)).collect(); - - // Make the sub-process opened by gc exe inherit its attribute. - let sa = SECURITY_ATTRIBUTES { - nLength: size_of::() as u32, - lpSecurityDescriptor: ptr::null_mut(), - bInheritHandle: 1, + use windows_sys::Win32::Storage::FileSystem::{ + FILE_FLAG_DELETE_ON_CLOSE, FILE_SHARE_DELETE, FILE_SHARE_READ, }; - let _g = unsafe { - // Open an inheritable handle to the gc exe marked - // FILE_FLAG_DELETE_ON_CLOSE. - let gc_handle = CreateFileW( - gc_exe_win.as_ptr(), - GENERIC_READ, - FILE_SHARE_READ | FILE_SHARE_DELETE, - &sa, - OPEN_EXISTING, - FILE_FLAG_DELETE_ON_CLOSE, - ptr::null_mut(), - ); - - if gc_handle == INVALID_HANDLE_VALUE { - let err = io::Error::last_os_error(); - return Err(err).context(CliError::WindowsUninstallMadness); - } + // Copy the running executable so GC does not depend on the installed copy. + let rustup_path = utils::current_exe()?; + let mut source = File::open(&rustup_path) + .with_context(|| format!("could not open rustup '{}'", rustup_path.display()))?; + // Use the system temporary directory so GC creation does not require + // write access to CARGO_HOME's parent. + let mut gc_file = tempfile::Builder::new() + .prefix("rustup-gc-") + .suffix(".exe") + .tempfile() + .context("error creating temporary GC executable")?; + // copy_file_symlink_to_source would create a link when the source is a + // symlink. io::copy writes its contents into this independent regular file, + // so DELETE_ON_CLOSE applies to the GC copy rather than the source target. + io::copy(&mut source, gc_file.as_file_mut()) + .with_context(|| format!("could not copy rustup from '{}'", rustup_path.display()))?; + // Close the write handle before opening the executable for reading. + let gc_exe = gc_file.into_temp_path(); + // OpenOptions preserves the read, sharing and delete-on-close flags while + // letting File own the handle until it is passed to Command below. + let gc_handle = OpenOptions::new() + .read(true) + .share_mode(FILE_SHARE_READ | FILE_SHARE_DELETE) + .custom_flags(FILE_FLAG_DELETE_ON_CLOSE) + .open(&gc_exe) + .context(CliError::WindowsUninstallMadness)?; - scopeguard::guard(gc_handle, |h| { - let _ = CloseHandle(h); - }) - }; + // Transfer cleanup to Windows only after the DELETE_ON_CLOSE handle is + // open. Until then, TempPath attempts cleanup if preparation fails. + let gc_exe = gc_exe.keep()?; - Command::new(gc_exe) + // Pass the file as GC stdin so the standard library manages inheritance. + // Command retains the parent handle after spawn; keep it alive through the sleep. + let mut command = Command::new(gc_exe); + command + .stdin(gc_handle) .env(GC_MODIFY_PATH, if no_modify_path { "0" } else { "1" }) .spawn() .context(CliError::WindowsUninstallMadness)?; @@ -867,6 +855,47 @@ mod tests { } } + #[test] + fn uninstall_registry_uses_resolved_bin_home() { + for category in [false, true] { + let id = test_id(); + let dirs = tempfile::tempdir().unwrap(); + let cargo_home = dirs.path().join("cargo home"); + let bin_home = dirs.path().join("category bin"); + let tp = TestProcess::with_vars(HashMap::from([ + (RUSTUP_REGISTRY_TEST_ID.to_owned(), id), + ( + "CARGO_HOME".to_owned(), + cargo_home.to_str().unwrap().to_owned(), + ), + ( + "RUSTUP_BIN_HOME".to_owned(), + bin_home.to_str().unwrap().to_owned(), + ), + ( + "RUSTUP_USE_CATEGORY_HOME".to_owned(), + if category { "1" } else { "0" }.to_owned(), + ), + ])); + add_uninstall_registry_entry(&tp.process).unwrap(); + let expected = if category { + bin_home + } else { + cargo_home.join("bin") + }; + assert_eq!( + rustup_uninstall_registry_key(&tp.process) + .unwrap() + .get_string("UninstallString") + .unwrap(), + format!( + "\"{}\" self uninstall", + expected.join("rustup.exe").display() + ) + ); + } + } + #[test] fn windows_registry_isolated_per_test_id() { let first_id = test_id(); @@ -1061,7 +1090,7 @@ mod tests { // Ok(None) signals no change to the PATH setting layer assert_eq!( None, - _with_path_cargo_home_bin(|_, _| panic!("called"), &tp.process).unwrap() + _with_path(|_, _| panic!("called"), Path::new("ignored"), &tp.process).unwrap() ); assert_eq!( diff --git a/src/config.rs b/src/config.rs index f8765924d3..05b06016f6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -317,12 +317,14 @@ pub(crate) const UNIX_FALLBACK_SETTINGS: &str = "/etc/rustup/settings.toml"; pub(crate) struct Cfg<'a> { pub profile_override: Option, - pub rustup_dir: PathBuf, pub settings_file: SettingsFile, state_file: StateFile, fallback_settings: Option, pub toolchains_dir: PathBuf, - update_hash_dir: PathBuf, + pub rustup_cache_dir: PathBuf, + pub rustup_config_dir: PathBuf, + pub rustup_data_dir: PathBuf, + pub rustup_state_dir: PathBuf, pub download_dir: PathBuf, pub toolchain_override: Option>, env_override: Option>, @@ -349,11 +351,20 @@ impl<'a> Cfg<'a> { process: &'a Process, ) -> anyhow::Result { // Set up the rustup home directory - let rustup_dir = process.rustup_home()?; - - utils::ensure_dir_exists("home", &rustup_dir)?; + let home_dirs = process.home_dirs()?; + let rustup_cache_dir = home_dirs.cache; + let rustup_config_dir = home_dirs.config; + let rustup_data_dir = home_dirs.data; + let rustup_state_dir = home_dirs.state; + + if process.use_category_home() { + utils::ensure_dir_exists("config home", &rustup_config_dir)?; + utils::ensure_dir_exists("state home", &rustup_state_dir)?; + } else { + utils::ensure_dir_exists("home", &rustup_config_dir)?; + } - let settings_file = SettingsFile::new(rustup_dir.join("settings.toml")); + let settings_file = SettingsFile::new(rustup_config_dir.join("settings.toml")); settings_file.with(|s| { debug!("read metadata version: {}", s.version); if s.version == MetadataVersion::default() { @@ -365,7 +376,7 @@ impl<'a> Cfg<'a> { } })?; - let state_file = StateFile::new(rustup_dir.join("state.toml")); + let state_file = StateFile::new(rustup_state_dir.join("state.toml")); // Centralised file for multi-user systems to provide admin/distributor set initial values. #[cfg(unix)] @@ -380,9 +391,8 @@ impl<'a> Cfg<'a> { #[cfg(windows)] let fallback_settings = None; - let toolchains_dir = rustup_dir.join("toolchains"); - let update_hash_dir = rustup_dir.join("update-hashes"); - let download_dir = rustup_dir.join("downloads"); + let toolchains_dir = rustup_data_dir.join("toolchains"); + let download_dir = rustup_cache_dir.join("downloads"); // Environment override let env_override = match &process.var_opt("RUSTUP_TOOLCHAIN")? { @@ -395,12 +405,14 @@ impl<'a> Cfg<'a> { let cfg = Self { profile_override: None, - rustup_dir, settings_file, state_file, fallback_settings, toolchains_dir, - update_hash_dir, + rustup_cache_dir, + rustup_config_dir, + rustup_data_dir, + rustup_state_dir, download_dir, toolchain_override: None, env_override, @@ -529,11 +541,12 @@ impl<'a> Cfg<'a> { toolchain: &ToolchainDesc, create_parent: bool, ) -> anyhow::Result { + let update_hash_dir = self.rustup_cache_dir.join("update-hashes"); if create_parent { - utils::ensure_dir_exists("update-hash", &self.update_hash_dir)?; + utils::ensure_dir_exists("update-hash", &update_hash_dir)?; } - Ok(self.update_hash_dir.join(toolchain.to_string())) + Ok(update_hash_dir.join(toolchain.to_string())) } #[tracing::instrument(level = "trace", skip_all)] @@ -562,7 +575,10 @@ impl<'a> Cfg<'a> { } // Also delete the update hashes - let files = utils::read_dir("update hashes", &self.update_hash_dir)?; + let files = utils::read_dir( + "update hashes", + &self.rustup_cache_dir.join("update-hashes"), + )?; for file in files { let file = file.context("IO Error reading update hashes")?; utils::remove_file("update hash", &file.path())?; @@ -1179,12 +1195,14 @@ impl Debug for Cfg<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let Self { profile_override, - rustup_dir, settings_file, state_file, fallback_settings, toolchains_dir, - update_hash_dir, + rustup_cache_dir, + rustup_config_dir, + rustup_data_dir, + rustup_state_dir, download_dir, toolchain_override, env_override, @@ -1198,12 +1216,14 @@ impl Debug for Cfg<'_> { f.debug_struct("Cfg") .field("profile_override", profile_override) - .field("rustup_dir", rustup_dir) .field("settings_file", settings_file) .field("state_file", state_file) .field("fallback_settings", fallback_settings) .field("toolchains_dir", toolchains_dir) - .field("update_hash_dir", update_hash_dir) + .field("rustup_cache_dir", rustup_cache_dir) + .field("rustup_config_dir", rustup_config_dir) + .field("rustup_data_dir", rustup_data_dir) + .field("rustup_state_dir", rustup_state_dir) .field("download_dir", download_dir) .field("toolchain_override", toolchain_override) .field("env_override", env_override) @@ -1309,6 +1329,24 @@ const FALLBACK_RELEASE_DATE: &str = "2026-04-17"; #[cfg(test)] mod tests { + #[cfg(unix)] + #[test] + fn category_config_does_not_require_legacy_home() { + let root = tempfile::tempdir().unwrap(); + let mut vars = std::collections::HashMap::new(); + vars.insert("RUSTUP_USE_CATEGORY_HOME".to_owned(), "1".to_owned()); + for category in ["CONFIG", "CACHE", "DATA", "STATE"] { + vars.insert( + format!("RUSTUP_{category}_HOME"), + root.path().join(category).display().to_string(), + ); + } + let process = crate::process::TestProcess::with_vars(vars); + assert!(process.process.rustup_home().is_err()); + let cfg = Cfg::from_env(root.path().to_owned(), false, false, &process.process).unwrap(); + assert_eq!(cfg.rustup_config_dir, root.path().join("CONFIG")); + } + use super::*; #[test] diff --git a/src/dist/download.rs b/src/dist/download.rs index 9c42383794..e4e5c79845 100644 --- a/src/dist/download.rs +++ b/src/dist/download.rs @@ -42,7 +42,7 @@ impl<'a> DownloadCfg<'a> { pub(crate) fn new(cfg: &'a Cfg<'a>) -> Self { DownloadCfg { tmp_cx: Arc::new(temp::Context::new( - cfg.rustup_dir.join("tmp"), + cfg.rustup_cache_dir.join("tmp"), cfg.dist_root_server.as_str(), )), download_dir: &cfg.download_dir, diff --git a/src/process.rs b/src/process.rs index 177b8a790b..b639fcb65a 100644 --- a/src/process.rs +++ b/src/process.rs @@ -18,6 +18,7 @@ use std::{ thread, }; +use ::home::env as home_env; use anstream::ColorChoice; use anyhow::{Context, bail}; use indicatif::ProgressDrawTarget; @@ -35,6 +36,8 @@ use crate::{ }; mod file_source; +mod home; +pub(crate) use home::HomeDirs; mod terminal_source; pub use terminal_source::ColorableTerminal; @@ -65,15 +68,68 @@ impl Process { } pub(crate) fn home_dir(&self) -> Option { - home::env::home_dir_with_env(self) + home_env::home_dir_with_env(self) } pub(crate) fn cargo_home(&self) -> anyhow::Result { - home::env::cargo_home_with_env(self).context("failed to determine cargo home") + home_env::cargo_home_with_env(self).context("failed to determine cargo home") } pub(crate) fn rustup_home(&self) -> anyhow::Result { - home::env::rustup_home_with_env(self).context("failed to determine rustup home dir") + home_env::rustup_home_with_env(self).context("failed to determine rustup home dir") + } + + /// Returns Rustup's cache, config, data, and state directories. + /// + /// Category mode uses each non-empty `RUSTUP__HOME`, then a + /// non-empty `RUSTUP_HOME`, then the platform default, then `~/.rustup`. + /// Legacy mode uses the resolved Rustup home for all four categories. + /// See [`home`] for platform defaults and path rules. + pub(crate) fn home_dirs(&self) -> io::Result { + if self.use_category_home() { + home::category_homes(self) + } else { + let home = home_env::rustup_home_with_env(self)?; + Ok(HomeDirs { + cache: home.clone(), + config: home.clone(), + data: home.clone(), + state: home, + }) + } + } + + /// Returns Rustup's binary installation directory. + /// + /// Category mode uses a non-empty `RUSTUP_BIN_HOME`, then a non-empty + /// `CARGO_HOME` with `bin` appended, then the platform default, then + /// `~/.cargo/bin`. Legacy mode appends `bin` to the resolved Cargo home. + pub(crate) fn rustup_bin_home(&self) -> io::Result { + if self.use_category_home() { + home::bin_home(self) + } else { + Ok(home_env::cargo_home_with_env(self)?.join("bin")) + } + } + + /// Returns the directory containing Rustup's shell environment scripts. + /// Uses the config home in category mode, or the Cargo home in legacy mode. + #[cfg(any(unix, test))] + pub(crate) fn rustup_env_home(&self) -> io::Result { + if self.use_category_home() { + // TODO: should this be in config home or state config home? + // Or we should just remove this once category mode is shipped + home::category_home(home::HomeCategory::Config, self) + } else { + home_env::cargo_home_with_env(self) + } + } + + /// Category mode is enabled when `RUSTUP_USE_CATEGORY_HOME` is non-empty + /// and not "0"; values such as "false" also enable it. + pub(crate) fn use_category_home(&self) -> bool { + self.var_os("RUSTUP_USE_CATEGORY_HOME") + .is_some_and(|value| value != "0") } pub fn io_thread_count(&self) -> anyhow::Result { @@ -302,10 +358,10 @@ impl From for usize { } } -impl home::env::Env for Process { +impl home_env::Env for Process { fn home_dir(&self) -> Option { match self { - Self::OsProcess(_) => home::env::OS_ENV.home_dir(), + Self::OsProcess(_) => home_env::OS_ENV.home_dir(), #[cfg(feature = "test")] Self::TestProcess(_) => self.var("HOME").ok().map(|v| v.into()), } @@ -313,7 +369,7 @@ impl home::env::Env for Process { fn current_dir(&self) -> Result { match self { - Self::OsProcess(_) => home::env::OS_ENV.current_dir(), + Self::OsProcess(_) => home_env::OS_ENV.current_dir(), #[cfg(feature = "test")] Self::TestProcess(_) => self.current_dir(), } @@ -438,7 +494,7 @@ pub struct TestContext { #[cfg(test)] mod tests { - use std::collections::HashMap; + use std::{collections::HashMap, path::Path}; use super::*; use crate::{process::TestProcess, test::Env}; @@ -459,4 +515,76 @@ mod tests { // non-tty + `auto` does not enable the colors. assert_color_choice("aUTo", false, ColorChoice::Never); } + + #[test] + fn category_mode_disabled_uses_legacy_homes() -> io::Result<()> { + let mut vars = HashMap::new(); + vars.env("HOME", Path::new("/home")); + vars.env("RUSTUP_STATE_HOME", Path::new("/split")); + vars.env("RUSTUP_BIN_HOME", Path::new("/split/bin")); + + let process = test_process(Path::new("/work"), vars.clone()); + assert_eq!( + process.home_dirs()?, + HomeDirs { + cache: "/home/.rustup".into(), + config: "/home/.rustup".into(), + data: "/home/.rustup".into(), + state: "/home/.rustup".into(), + } + ); + assert_eq!(process.rustup_bin_home()?, Path::new("/home/.cargo/bin")); + assert_eq!(process.rustup_env_home()?, Path::new("/home/.cargo")); + + vars.env("RUSTUP_HOME", Path::new("/legacy")); + vars.env("CARGO_HOME", Path::new("/cargo")); + let process = test_process(Path::new("/work"), vars); + assert_eq!( + process.home_dirs()?, + HomeDirs { + cache: "/legacy".into(), + config: "/legacy".into(), + data: "/legacy".into(), + state: "/legacy".into(), + } + ); + assert_eq!(process.rustup_bin_home()?, Path::new("/cargo/bin")); + assert_eq!(process.rustup_env_home()?, Path::new("/cargo")); + Ok(()) + } + + #[test] + fn category_mode_enabled_uses_category_homes() -> io::Result<()> { + let mut vars = HashMap::new(); + vars.env("RUSTUP_CACHE_HOME", "cache"); + vars.env("RUSTUP_CONFIG_HOME", "config"); + vars.env("RUSTUP_DATA_HOME", "data"); + vars.env("RUSTUP_STATE_HOME", "state"); + vars.env("RUSTUP_BIN_HOME", "bin"); + + for mode in ["1", "false"] { + vars.env("RUSTUP_USE_CATEGORY_HOME", mode); + let process = test_process(Path::new("/work"), vars.clone()); + assert_eq!( + process.home_dirs()?, + HomeDirs { + cache: "cache".into(), + config: "config".into(), + data: "data".into(), + state: "state".into(), + } + ); + assert_eq!(process.rustup_bin_home()?, Path::new("bin")); + assert_eq!(process.rustup_env_home()?, Path::new("config")); + } + Ok(()) + } + + fn test_process(cwd: &Path, vars: HashMap) -> Process { + Process::TestProcess(TestContext { + cwd: cwd.into(), + vars, + ..Default::default() + }) + } } diff --git a/src/process/home.rs b/src/process/home.rs new file mode 100644 index 0000000000..c7386b6307 --- /dev/null +++ b/src/process/home.rs @@ -0,0 +1,330 @@ +//! Resolve Rustup's directories for the opt-in category-home layout. +//! +//! `Process` selects the layout using `RUSTUP_USE_CATEGORY_HOME`: a non-empty +//! value other than "0" enables category mode. This module provides the path +//! resolvers; it does not check the mode switch itself. +//! +//! In category mode, cache, config, data, and state each resolve independently: +//! +//! 1. Use a non-empty `RUSTUP__HOME` as the complete directory path. +//! 2. Otherwise, use a non-empty `RUSTUP_HOME`, resolving relative paths against +//! the current directory. +//! 3. Otherwise, use the platform's category directory with `rustup` appended. +//! 4. If the platform directory cannot be determined, use `~/.rustup`. +//! +//! On Unix, the platform directory comes from an absolute `XDG__HOME`, +//! or defaults to `~/.cache`, `~/.config`, `~/.local/share`, or `~/.local/state`. +//! Empty or relative XDG values are ignored. Windows uses Known Folders and +//! does not consult XDG variables. +//! +//! The bin directory resolves in this order: +//! +//! 1. Use a non-empty `RUSTUP_BIN_HOME` as the complete directory path. +//! 2. Otherwise, use a non-empty `CARGO_HOME` with `bin` appended, resolving +//! relative paths against the current directory. +//! 3. Otherwise, use `~/.local/bin` (currently `%USERPROFILE%/.local/bin` on +//! Windows). +//! 4. If the platform directory cannot be determined, use `~/.cargo/bin`. +//! +//! TODO: The Windows bin default remains to be decided between +//! `%LOCALAPPDATA%/rustup/bin` and `%LOCALAPPDATA%/Programs/Rustup/bin`. +//! Explicit category and bin overrides are used as supplied, including relative +//! paths. +//! +//! When category mode is disabled, `Process` uses the `home` crate +//! APIs: `RUSTUP_HOME` or `~/.rustup` for all four categories, and `CARGO_HOME/bin` +//! or `~/.cargo/bin` for binaries. Category overrides have no effect in that mode. + +use std::{io, path::PathBuf}; + +use home::env::{Env, cargo_home_with_env, home_dir_with_env, rustup_home_with_env}; + +#[cfg(unix)] +use self::unix::category_dir; +#[cfg(windows)] +use self::windows::category_dir; + +#[cfg(unix)] +mod unix; +#[cfg(windows)] +mod windows; + +#[derive(Debug, Eq, PartialEq)] +pub(crate) struct HomeDirs { + pub(crate) cache: PathBuf, + pub(crate) config: PathBuf, + pub(crate) data: PathBuf, + pub(crate) state: PathBuf, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum HomeCategory { + Cache, + Config, + Data, + State, +} + +impl HomeCategory { + const fn override_env_var(self) -> &'static str { + match self { + Self::Cache => "RUSTUP_CACHE_HOME", + Self::Config => "RUSTUP_CONFIG_HOME", + Self::Data => "RUSTUP_DATA_HOME", + Self::State => "RUSTUP_STATE_HOME", + } + } +} + +pub(super) fn category_homes(env: &impl Env) -> io::Result { + Ok(HomeDirs { + cache: category_home(HomeCategory::Cache, env)?, + config: category_home(HomeCategory::Config, env)?, + data: category_home(HomeCategory::Data, env)?, + state: category_home(HomeCategory::State, env)?, + }) +} + +/// Resolves a category directory in category mode. +/// +/// Respects an explicit `RUSTUP_HOME` unless `RUSTUP__HOME` overrides it. +/// Ignores empty overrides, preserves relative category paths, and resolves +/// relative `RUSTUP_HOME` paths against the current directory. +/// +/// Otherwise, appends `rustup` to the platform's category directory: an XDG +/// directory on Unix or a Known Folder on Windows. Falls back to the legacy +/// Rustup home if the platform directory cannot be determined. +pub(super) fn category_home(category: HomeCategory, env: &impl Env) -> io::Result { + if let Some(path) = path_from_env(category.override_env_var(), env) { + return Ok(path); + } + if let Some(path) = path_from_env("RUSTUP_HOME", env) { + if path.is_absolute() { + return Ok(path); + } + let mut cwd = env.current_dir()?; + cwd.push(path); + return Ok(cwd); + } + category_dir(category, env) + .map(|path| path.join("rustup")) + .or_else(|_| rustup_home_with_env(env)) +} + +/// Resolves the binary directory in category mode. +/// +/// Respects an explicit `CARGO_HOME` unless `RUSTUP_BIN_HOME` overrides it, +/// consistent with Cargo's compatibility policy from the [Cargo XDG paths discussion]. +/// +/// XDG uses the shared `$HOME/.local/bin` directory without an `XDG_BIN_HOME` +/// variable or a `rustup` subdirectory. The Windows default is still undecided +/// (see the module-level TODO). These differences require separate bin directory +/// resolution. +/// +/// See . +/// +/// [Cargo XDG paths discussion]: https://blog.rust-lang.org/inside-rust/2025/10/01/this-development-cycle-in-cargo-1.90/#all-hands-xdg-paths +pub(super) fn bin_home(env: &impl Env) -> io::Result { + if let Some(path) = path_from_env("RUSTUP_BIN_HOME", env) { + return Ok(path); + } + if path_from_env("CARGO_HOME", env).is_none() + && let Some(path) = home_dir_with_env(env).filter(|path| path.is_absolute()) + { + return Ok(path.join(".local/bin")); + } + Ok(cargo_home_with_env(env)?.join("bin")) +} + +fn path_from_env(key: &str, env: &impl Env) -> Option { + env.var_os(key) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) +} + +#[cfg(test)] +mod tests { + #[cfg(windows)] + use std::ffi::OsStr; + #[cfg(unix)] + use std::fs; + use std::{collections::HashMap, path::Path}; + + use super::*; + #[cfg(unix)] + use crate::process::TestProcess; + use crate::{ + process::{Process, TestContext}, + test::Env as _, + }; + + #[test] + fn uses_direct_rustup_precedence() -> io::Result<()> { + let cwd = Path::new("/work"); + let mut vars = HashMap::new(); + vars.env("RUSTUP_STATE_HOME", "state"); + vars.env("RUSTUP_HOME", "rustup"); + vars.env("HOME", Path::new("/home")); + vars.env("XDG_STATE_HOME", Path::new("/xdg/state")); + + let process = test_env(cwd, vars.clone()); + let homes = category_homes(&process)?; + assert_eq!(homes.cache, Path::new("/work/rustup")); + assert_eq!(homes.config, Path::new("/work/rustup")); + assert_eq!(homes.data, Path::new("/work/rustup")); + assert_eq!(homes.state, Path::new("state")); + + vars.env("RUSTUP_STATE_HOME", ""); + assert_eq!( + category_homes(&test_env(cwd, vars))?, + HomeDirs { + cache: "/work/rustup".into(), + config: "/work/rustup".into(), + data: "/work/rustup".into(), + state: "/work/rustup".into(), + } + ); + Ok(()) + } + + #[test] + fn uses_rustup_bin_home_override() -> io::Result<()> { + let cwd = Path::new("/work"); + let mut vars = HashMap::new(); + vars.env("RUSTUP_BIN_HOME", "bin"); + vars.env("RUSTUP_CONFIG_HOME", "config"); + vars.env("CARGO_HOME", "cargo"); + + let process = test_env(cwd, vars.clone()); + assert_eq!(bin_home(&process)?, Path::new("bin")); + assert_eq!( + category_home(HomeCategory::Config, &process)?, + Path::new("config") + ); + + vars.env("RUSTUP_BIN_HOME", ""); + assert_eq!( + bin_home(&test_env(cwd, vars))?, + Path::new("/work/cargo/bin") + ); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn uses_unix_platform_defaults() -> io::Result<()> { + let mut vars = HashMap::new(); + vars.env("RUSTUP_HOME", ""); + vars.env("RUSTUP_STATE_HOME", ""); + vars.env("HOME", Path::new("/home")); + vars.env("XDG_STATE_HOME", Path::new("/xdg/state")); + + let homes = category_homes(&test_env(Path::new("/work"), vars.clone()))?; + assert_eq!(homes.state, Path::new("/xdg/state/rustup")); + + vars.env("XDG_STATE_HOME", Path::new("xdg/state")); + let process = TestProcess::new(Path::new("/work"), &[] as &[&str], vars.clone(), ""); + let homes = category_homes(&process.process)?; + assert_eq!(homes.state, Path::new("/home/.local/state/rustup")); + assert_eq!( + process.stderr(), + b"warn: ignoring relative XDG_STATE_HOME path xdg/state; falling back to /home/.local/state\n" + ); + + vars.env("XDG_STATE_HOME", ""); + let homes = category_homes(&test_env(Path::new("/work"), vars))?; + assert_eq!(homes.state, Path::new("/home/.local/state/rustup")); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn ignores_existing_legacy_home() -> io::Result<()> { + let home = tempfile::tempdir()?; + fs::create_dir(home.path().join(".rustup"))?; + let mut vars = HashMap::new(); + vars.env("HOME", home.path()); + + let homes = category_homes(&test_env(Path::new("/work"), vars))?; + assert_eq!(homes.cache, home.path().join(".cache/rustup")); + assert_eq!(homes.config, home.path().join(".config/rustup")); + assert_eq!(homes.data, home.path().join(".local/share/rustup")); + assert_eq!(homes.state, home.path().join(".local/state/rustup")); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn without_absolute_home() -> io::Result<()> { + let mut vars = HashMap::new(); + let process = test_env(Path::new("/work"), vars.clone()); + + assert!(category_homes(&process).is_err()); + assert!(bin_home(&process).is_err()); + + // Platform defaults require an absolute home, while legacy paths do not. + vars.env("HOME", "relative"); + let process = test_env(Path::new("/work"), vars); + assert_eq!( + category_homes(&process)?, + HomeDirs { + cache: "relative/.rustup".into(), + config: "relative/.rustup".into(), + data: "relative/.rustup".into(), + state: "relative/.rustup".into(), + } + ); + assert_eq!(bin_home(&process)?, Path::new("relative/.cargo/bin")); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn uses_cargo_home_or_bin_platform_default() -> io::Result<()> { + let mut vars = HashMap::new(); + vars.env("RUSTUP_BIN_HOME", ""); + vars.env("HOME", Path::new("/home")); + + for (cargo_home, expected_bin_home) in [("", "/home/.local/bin"), ("/cargo", "/cargo/bin")] + { + let mut vars = vars.clone(); + vars.env("CARGO_HOME", cargo_home); + assert_eq!( + bin_home(&test_env(Path::new("/work"), vars))?, + Path::new(expected_bin_home) + ); + } + Ok(()) + } + + #[cfg(windows)] + #[test] + fn uses_windows_platform_defaults() -> io::Result<()> { + let mut vars = HashMap::new(); + vars.env("HOME", Path::new(r"C:\Users\rustup-test")); + let process = test_env(Path::new(r"C:\work"), vars); + + let homes = category_homes(&process)?; + assert!(homes.cache.is_absolute()); + assert!(homes.config.is_absolute()); + assert_eq!(homes.config, homes.data); + assert_eq!(homes.config, homes.state); + assert_ne!(homes.cache, homes.config); + for home in [&homes.cache, &homes.config, &homes.data, &homes.state] { + assert_eq!(home.file_name(), Some(OsStr::new("rustup"))); + } + assert_eq!( + bin_home(&process)?, + Path::new(r"C:\Users\rustup-test").join(".local/bin") + ); + Ok(()) + } + + fn test_env(cwd: &Path, vars: HashMap) -> Process { + Process::TestProcess(TestContext { + cwd: cwd.into(), + vars, + ..Default::default() + }) + } +} diff --git a/src/process/home/unix.rs b/src/process/home/unix.rs new file mode 100644 index 0000000000..c0f8faf40b --- /dev/null +++ b/src/process/home/unix.rs @@ -0,0 +1,202 @@ +//! Unix XDG platform defaults. +//! +//! Empty and relative XDG values are ignored; fallback HOME must be absolute. + +use std::{ + io::{self, Result}, + path::PathBuf, +}; + +use home::env::{Env, home_dir_with_env}; +use tracing::warn; + +use super::{HomeCategory, path_from_env}; + +pub(super) fn category_dir(category: HomeCategory, env: &impl Env) -> Result { + let xdg_env_var = category.xdg_env_var(); + let relative_xdg_path = match path_from_env(xdg_env_var, env) { + Some(path) if path.is_absolute() => return Ok(path), + path => path, + }; + let Some(path) = home_dir_with_env(env) else { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "home directory is not set", + )); + }; + if !path.is_absolute() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "home directory is not absolute", + )); + } + + let fallback = path.join(category.fallback_subdir()); + if let Some(relative) = relative_xdg_path { + warn!( + "ignoring relative {xdg_env_var} path {}; falling back to {}", + relative.display(), + fallback.display() + ); + } + Ok(fallback) +} + +impl HomeCategory { + const fn xdg_env_var(self) -> &'static str { + match self { + Self::Cache => "XDG_CACHE_HOME", + Self::Config => "XDG_CONFIG_HOME", + Self::Data => "XDG_DATA_HOME", + Self::State => "XDG_STATE_HOME", + } + } + + const fn fallback_subdir(self) -> &'static str { + match self { + Self::Cache => ".cache", + Self::Config => ".config", + Self::Data => ".local/share", + Self::State => ".local/state", + } + } +} + +#[cfg(test)] +mod tests { + use std::{assert_matches, ffi::OsString, path::Path}; + + use super::*; + + #[test] + fn explicit_xdg_values_do_not_need_home() -> Result<()> { + let env = TestEnv { + xdg: XdgStatus::Explicit, + home: None, + }; + + for category in CATEGORIES { + assert_eq!(category_dir(category, &env)?, category.explicit_path()); + } + Ok(()) + } + + #[test] + fn missing_xdg_values_use_defaults() -> Result<()> { + assert_category_fallbacks(XdgStatus::Missing) + } + + #[test] + fn empty_xdg_values_use_defaults() -> Result<()> { + assert_category_fallbacks(XdgStatus::Empty) + } + + #[test] + fn relative_xdg_values_use_defaults() -> Result<()> { + assert_category_fallbacks(XdgStatus::Relative) + } + + #[test] + fn missing_home_errors() { + let env = TestEnv { + xdg: XdgStatus::Missing, + home: None, + }; + + for category in CATEGORIES { + assert_matches!( + category_dir(category, &env), + Err(error) + if error.kind() == io::ErrorKind::NotFound + && error.to_string() == "home directory is not set" + ); + } + } + + #[test] + fn relative_home_errors() { + let env = TestEnv { + xdg: XdgStatus::Missing, + home: Some(Path::new("relative/home")), + }; + + for category in CATEGORIES { + assert_matches!( + category_dir(category, &env), + Err(error) + if error.kind() == io::ErrorKind::InvalidData + && error.to_string() == "home directory is not absolute" + ); + } + } + + fn assert_category_fallbacks(xdg: XdgStatus) -> Result<()> { + let env = TestEnv { + xdg, + home: Some(Path::new(TEST_HOME)), + }; + + for category in CATEGORIES { + assert_eq!( + category_dir(category, &env)?, + Path::new(TEST_HOME).join(category.fallback_subdir()), + ); + } + Ok(()) + } + + struct TestEnv<'a> { + xdg: XdgStatus, + home: Option<&'a Path>, + } + + impl Env for TestEnv<'_> { + fn home_dir(&self) -> Option { + self.home.map(Path::to_path_buf) + } + + fn current_dir(&self) -> Result { + panic!("current_dir must not be queried") + } + + fn var_os(&self, key: &str) -> Option { + let category = CATEGORIES + .into_iter() + .find(|category| key == category.xdg_env_var())?; + match self.xdg { + XdgStatus::Empty => Some(OsString::new()), + XdgStatus::Explicit => Some(category.explicit_path().into()), + XdgStatus::Missing => None, + XdgStatus::Relative => Some("relative/path".into()), + } + } + } + + #[derive(Clone, Copy)] + enum XdgStatus { + Empty, + Explicit, + Missing, + Relative, + } + + const TEST_HOME: &str = "/home/rustup-test"; + + const CATEGORIES: [HomeCategory; 4] = [ + HomeCategory::Cache, + HomeCategory::Config, + HomeCategory::Data, + HomeCategory::State, + ]; + + impl HomeCategory { + fn explicit_path(self) -> &'static Path { + Path::new(match self { + Self::Cache => "/srv/cache", + Self::Config => "/srv/config", + Self::Data => "/srv/data", + Self::State => "/srv/state", + }) + } + } +} diff --git a/src/process/home/windows.rs b/src/process/home/windows.rs new file mode 100644 index 0000000000..5986ad748b --- /dev/null +++ b/src/process/home/windows.rs @@ -0,0 +1,85 @@ +use std::{ffi::OsString, io, os::windows::ffi::OsStringExt, path::PathBuf, ptr, slice}; + +use home::env::Env; +use windows_result::HRESULT; +use windows_sys::Win32::{ + System::Com::CoTaskMemFree, + UI::Shell::{ + FOLDERID_LocalAppData, FOLDERID_RoamingAppData, KF_FLAG_DONT_VERIFY, SHGetKnownFolderPath, + }, +}; + +use super::HomeCategory; + +pub(super) fn category_dir(category: HomeCategory, _env: &impl Env) -> io::Result { + known_folder(match category { + HomeCategory::Cache => &FOLDERID_LocalAppData, + HomeCategory::Config | HomeCategory::Data | HomeCategory::State => &FOLDERID_RoamingAppData, + }) +} + +fn known_folder(id: &windows_sys::core::GUID) -> io::Result { + let mut path = ptr::null_mut(); + + // SAFETY: `SHGetKnownFolderPath` initializes `path` with a CoTaskMem-allocated, + // null-terminated UTF-16 string on success. `CoTaskMemFree` accepts null and is + // called on both result paths; the success path reads only through the terminator. + unsafe { + let result = HRESULT(SHGetKnownFolderPath( + id, + KF_FLAG_DONT_VERIFY as u32, + ptr::null_mut(), + &mut path, + )); + if let Err(error) = result.ok() { + CoTaskMemFree(path.cast()); + return Err(error.into()); + } + + let result = OsString::from_wide(slice::from_raw_parts(path, wcslen(path))); + CoTaskMemFree(path.cast()); + Ok(result.into()) + } +} + +unsafe extern "C" { + fn wcslen(buf: *const u16) -> usize; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn categories_use_known_folders_without_environment() -> io::Result<()> { + let env = PanicEnv; + let local = known_folder(&FOLDERID_LocalAppData)?; + let roaming = known_folder(&FOLDERID_RoamingAppData)?; + + assert_eq!(category_dir(HomeCategory::Cache, &env)?, local); + for category in [ + HomeCategory::Config, + HomeCategory::Data, + HomeCategory::State, + ] { + assert_eq!(category_dir(category, &env)?, roaming); + } + Ok(()) + } + + struct PanicEnv; + + impl Env for PanicEnv { + fn home_dir(&self) -> Option { + panic!("home_dir must not be queried") + } + + fn current_dir(&self) -> io::Result { + panic!("current_dir must not be queried") + } + + fn var_os(&self, _key: &str) -> Option { + panic!("var_os must not be queried") + } + } +} diff --git a/src/test/clitools.rs b/src/test/clitools.rs index d2dad716b7..d67d79cf51 100644 --- a/src/test/clitools.rs +++ b/src/test/clitools.rs @@ -274,6 +274,10 @@ impl Config { } cmd.env("PATH", new_path); self.rustupdir.apply(cmd); + // Keep category mode and its bin override independent of the developer's environment. + // Individual tests can override these defaults after constructing the command. + cmd.env("RUSTUP_USE_CATEGORY_HOME", ""); + cmd.env("RUSTUP_BIN_HOME", ""); let distdir = match (&self.distdir, &self.const_dist_dir) { (None, None) => Path::new("no-such-distdir"), // mutable takes precedence @@ -791,6 +795,10 @@ async fn setup_test_state(test_dist_dir: TempDir) -> (TempDir, Config) { env::remove_var("CARGO"); env::remove_var("RUSTUP_AUTO_INSTALL"); env::remove_var("RUSTUP_UPDATE_ROOT"); + env::remove_var("RUSTUP_CACHE_HOME"); + env::remove_var("RUSTUP_CONFIG_HOME"); + env::remove_var("RUSTUP_DATA_HOME"); + env::remove_var("RUSTUP_STATE_HOME"); env::remove_var("RUSTUP_TOOLCHAIN"); env::remove_var("SHELL"); env::remove_var("ZDOTDIR"); @@ -800,6 +808,9 @@ async fn setup_test_state(test_dist_dir: TempDir) -> (TempDir, Config) { env::set_var("TERM", "dumb"); // Removed to avoid leaking the developer's environment into the test env::remove_var("XDG_CONFIG_HOME"); + env::remove_var("XDG_CACHE_HOME"); + env::remove_var("XDG_DATA_HOME"); + env::remove_var("XDG_STATE_HOME"); match env::var("RUSTUP_BACKTRACE") { Ok(val) => env::set_var("RUST_BACKTRACE", val), diff --git a/src/test/mock_bin_src.rs b/src/test/mock_bin_src.rs index c53eda3225..9f931beb76 100644 --- a/src/test/mock_bin_src.rs +++ b/src/test/mock_bin_src.rs @@ -100,6 +100,7 @@ fn main() { Some("--echo-current-exe") => { let mut out = io::stderr(); writeln!(out, "{}", std::env::current_exe().unwrap().display()).unwrap(); + } arg => panic!("bad mock proxy commandline: {:?}", arg), } diff --git a/src/toolchain.rs b/src/toolchain.rs index c369de8576..7be80bb064 100644 --- a/src/toolchain.rs +++ b/src/toolchain.rs @@ -171,15 +171,26 @@ impl<'a> Toolchain<'a> { // cargo home. Rustup does not read HOME on Windows whereas the older // versions of Cargo did. Rustup and Cargo should be in sync now (both // using the same `home` crate), but this is retained to ensure cargo - // and rustup agree in older versions. - if let Ok(cargo_home) = self.cfg.process.cargo_home() { + // and rustup agree in older versions in legacy mode. In category mode, + // only resolve a non-empty CARGO_HOME; otherwise leave the inherited + // environment unchanged so Cargo can choose its own default. + if (!self.cfg.process.use_category_home() + || self.cfg.process.var_os("CARGO_HOME").is_some()) + && let Ok(cargo_home) = self.cfg.process.cargo_home() + { cmd.env("CARGO_HOME", &cargo_home); } env_var::inc("RUST_RECURSION_COUNT", cmd, self.cfg.process); cmd.env("RUSTUP_TOOLCHAIN", format!("{}", self.name)); - cmd.env("RUSTUP_HOME", &self.cfg.rustup_dir); + if !self.cfg.process.use_category_home() { + cmd.env("RUSTUP_HOME", &self.cfg.rustup_data_dir); + } + cmd.env("RUSTUP_CACHE_HOME", &self.cfg.rustup_cache_dir); + cmd.env("RUSTUP_CONFIG_HOME", &self.cfg.rustup_config_dir); + cmd.env("RUSTUP_DATA_HOME", &self.cfg.rustup_data_dir); + cmd.env("RUSTUP_STATE_HOME", &self.cfg.rustup_state_dir); } /// Apply the appropriate LD path for a command being run from a toolchain. @@ -227,13 +238,13 @@ impl<'a> Toolchain<'a> { env_var::insert_path(sysenv::LOADER_PATH, new_path, None, cmd, self.cfg.process); - // Prepend CARGO_HOME/bin to the PATH variable so that we're sure to run + // Prepend the Rustup bin home to PATH so that we're sure to run // cargo/rustc via the proxy bins. There is no fallback case for if the // proxy bins don't exist. We'll just be running whatever happens to // be on the PATH. let mut path_entries = vec![]; - if let Ok(cargo_home) = self.cfg.process.cargo_home() { - path_entries.push(cargo_home.join("bin")); + if let Ok(rustup_bin_home) = self.cfg.process.rustup_bin_home() { + path_entries.push(rustup_bin_home); } // On Windows, we append the "bin" directory to PATH by default. diff --git a/src/toolchain/distributable.rs b/src/toolchain/distributable.rs index 3b46eb54a7..0b1d3900ba 100644 --- a/src/toolchain/distributable.rs +++ b/src/toolchain/distributable.rs @@ -225,7 +225,7 @@ impl<'a> DistributableToolchain<'a> { // the documentation for the lpCommandLine argument of CreateProcess. #[cfg(windows)] let exe_path = { - let fallback_dir = self.toolchain.cfg.rustup_dir.join("fallback"); + let fallback_dir = self.toolchain.cfg.rustup_data_dir.join("fallback"); fs::create_dir_all(&fallback_dir) .context("unable to create dir to hold fallback exe")?; let fallback_file = fallback_dir.join("cargo.exe"); diff --git a/tests/suite/cli_exact.rs b/tests/suite/cli_exact.rs index 20ebaf71bd..e62ac8aa69 100644 --- a/tests/suite/cli_exact.rs +++ b/tests/suite/cli_exact.rs @@ -657,6 +657,41 @@ help: run 'rustup default stable' to download the latest stable release of Rust "#]]); } +#[tokio::test] +async fn default_uses_config_home_and_forwards_it() { + let cx = CliTestContext::new(Scenario::SimpleV2).await; + let config_home = cx.config.current_dir().join("relative/config"); + let config_home_env = config_home.to_str().unwrap(); + std::fs::remove_file(cx.config.rustupdir.join("settings.toml")).unwrap(); + + cx.config + .expect_with_env( + ["rustup", "default", "stable"], + [ + ("RUSTUP_CONFIG_HOME", config_home_env), + ("RUSTUP_USE_CATEGORY_HOME", "1"), + ], + ) + .await + .is_ok(); + + assert!(config_home.join("settings.toml").is_file()); + assert!(!cx.config.rustupdir.has("settings.toml")); + + let rustc = cx + .config + .expect_with_env( + ["rustc", "+stable", "--echo-env", "RUSTUP_CONFIG_HOME"], + [ + ("RUSTUP_CONFIG_HOME", config_home_env), + ("RUSTUP_USE_CATEGORY_HOME", "1"), + ], + ) + .await; + rustc.is_ok(); + assert_eq!(rustc.output.stderr.trim(), config_home.to_string_lossy()); +} + #[tokio::test] async fn list_targets() { let cx = CliTestContext::new(Scenario::SimpleV2).await; diff --git a/tests/suite/cli_inst_interactive.rs b/tests/suite/cli_inst_interactive.rs index 07e84e98da..7bbaf205be 100644 --- a/tests/suite/cli_inst_interactive.rs +++ b/tests/suite/cli_inst_interactive.rs @@ -61,8 +61,16 @@ async fn smoke_case_install_no_modify_path() { // output on stderr, then an explicit blank line on stdout // before printing $toolchain installed run_input(&cx.config, &["rustup-init", "--no-modify-path"], "\n\n") + .extend_redactions([("[RUSTUP_DIR]", &cx.config.rustupdir.to_string())]) .with_stdout(snapbox::str![[r#" ... +Rustup metadata and toolchains will be installed into the Rustup +home directory, located at: + + [RUSTUP_DIR] + +This can be modified with the RUSTUP_HOME environment variable. +... This path needs to be in your PATH environment variable, but will not be added automatically. @@ -258,6 +266,107 @@ no active toolchain .is_ok(); } +#[tokio::test] +async fn install_with_split_homes_does_not_create_legacy_home() { + let cx = CliTestContext::new(Scenario::SimpleV2).await; + let config_home = cx.config.current_dir().join("relative/config"); + let state_home = cx.config.current_dir().join("relative/state"); + let mut cmd = cx.config.cmd( + "rustup-init", + ["-y", "--no-modify-path", "--default-toolchain", "none"], + ); + cmd.env_remove("RUSTUP_HOME"); + cmd.env("RUSTUP_CONFIG_HOME", "relative/config"); + cmd.env("RUSTUP_STATE_HOME", "relative/state"); + cmd.env("RUSTUP_DATA_HOME", "relative/data"); + cmd.env("RUSTUP_CACHE_HOME", "relative/cache"); + cmd.env("RUSTUP_USE_CATEGORY_HOME", "1"); + assert!(cmd.output().unwrap().status.success()); + + assert!(!cx.config.homedir.join(".rustup").exists()); + assert!(config_home.is_dir()); + assert!(state_home.is_dir()); +} + +#[tokio::test] +async fn install_displays_split_homes() { + let cx = CliTestContext::new(Scenario::SimpleV2).await; + let config_home = cx.config.current_dir().join("relative/config"); + let state_home = cx.config.current_dir().join("relative/state"); + let data_home = cx.config.current_dir().join("relative/data"); + let cache_home = cx.config.current_dir().join("relative/cache"); + let redactions = [ + ("[CONFIG_HOME]", config_home.clone()), + ("[STATE_HOME]", state_home.clone()), + ("[DATA_HOME]", data_home.clone()), + ("[CACHE_HOME]", cache_home.clone()), + ]; + + run_input_with_env( + &cx.config, + &["rustup-init", "--no-modify-path"], + "3\n", + &[ + ("RUSTUP_CONFIG_HOME", config_home.to_str().unwrap()), + ("RUSTUP_STATE_HOME", state_home.to_str().unwrap()), + ("RUSTUP_DATA_HOME", data_home.to_str().unwrap()), + ("RUSTUP_CACHE_HOME", cache_home.to_str().unwrap()), + ("RUSTUP_USE_CATEGORY_HOME", "1"), + ], + ) + .extend_redactions(redactions) + .with_stdout(snapbox::str![[r#" +... +Rustup will use these directories: + + config: [CONFIG_HOME] + state: [STATE_HOME] + data: [DATA_HOME] + cache: [CACHE_HOME] + +They can be modified individually with +RUSTUP_CONFIG_HOME, RUSTUP_STATE_HOME, RUSTUP_DATA_HOME, and +RUSTUP_CACHE_HOME. +... +"#]]) + .is_ok(); +} + +#[tokio::test] +async fn install_displays_category_overrides_when_homes_match_legacy() { + let cx = CliTestContext::new(Scenario::Empty).await; + let home = cx.config.rustupdir.to_string(); + run_input_with_env( + &cx.config, + &["rustup-init", "--no-modify-path"], + "3\n", + &[ + ("RUSTUP_USE_CATEGORY_HOME", "1"), + ("RUSTUP_HOME", &home), + ("RUSTUP_CONFIG_HOME", &home), + ("RUSTUP_STATE_HOME", &home), + ("RUSTUP_DATA_HOME", &home), + ("RUSTUP_CACHE_HOME", &home), + ], + ) + .extend_redactions([("[RUSTUP_DIR]", &home)]) + .with_stdout(snapbox::str![[r#" +... +Rustup will use these directories: + + config: [RUSTUP_DIR] + state: [RUSTUP_DIR] + data: [RUSTUP_DIR] + cache: [RUSTUP_DIR] + +They can be modified individually with +RUSTUP_CONFIG_HOME, RUSTUP_STATE_HOME, RUSTUP_DATA_HOME, and +RUSTUP_CACHE_HOME. +... +"#]]) + .is_ok(); +} + #[tokio::test] async fn with_no_toolchain_doesnt_hang() { let cx = CliTestContext::new(Scenario::SimpleV2).await; @@ -625,8 +734,12 @@ async fn install_warns_about_existing_settings_file() { .prefix("fakehome") .tempdir() .unwrap(); + let config_dir = tempfile::Builder::new() + .prefix("fakeconfig") + .tempdir() + .unwrap(); // Create `settings.toml` - let settings_file = temp_dir.path().join("settings.toml"); + let settings_file = config_dir.path().join("settings.toml"); raw::write_file( &settings_file, &format!( @@ -638,6 +751,7 @@ version = "12""#, ) .unwrap(); let temp_dir_path = temp_dir.path().to_str().unwrap(); + let config_dir_path = config_dir.path().to_str().unwrap(); let cx = CliTestContext::new(Scenario::SimpleV2).await; cx.config @@ -646,6 +760,8 @@ version = "12""#, [ ("RUSTUP_INIT_SKIP_PATH_CHECK", "no"), ("RUSTUP_HOME", temp_dir_path), + ("RUSTUP_CONFIG_HOME", config_dir_path), + ("RUSTUP_USE_CATEGORY_HOME", "1"), ], ) .await diff --git a/tests/suite/cli_paths.rs b/tests/suite/cli_paths.rs index cc723459ef..abdbb019c6 100644 --- a/tests/suite/cli_paths.rs +++ b/tests/suite/cli_paths.rs @@ -7,7 +7,7 @@ const INIT_NONE: [&str; 4] = ["rustup-init", "-y", "--default-toolchain", "none" #[cfg(unix)] mod unix { - use std::{fmt::Display, fs, path::PathBuf}; + use std::{env, ffi::OsStr, fmt::Display, fs, path::PathBuf, process::Command}; use rustup::{ test::{CliTestContext, Scenario}, @@ -41,9 +41,7 @@ export PATH="$HOME/apple/bin" #[tokio::test] async fn install_creates_necessary_scripts() { let cx = CliTestContext::new(Scenario::Empty).await; - // Override the test harness so that cargo home looks like - // $HOME/.cargo by removing CARGO_HOME from the environment, - // otherwise the literal path will be written to the file. + // Exercise the default Cargo home; newly generated paths are still absolute. let mut cmd = cx.config.cmd("rustup-init", &INIT_NONE[1..]); let files: Vec = [".cargo/env", ".profile", ".zshenv"] @@ -53,6 +51,7 @@ export PATH="$HOME/apple/bin" for file in &files { assert!(!file.exists()); } + // Remove the test harness override to exercise HOME/.cargo. cmd.env_remove("CARGO_HOME"); cmd.env("SHELL", "zsh"); assert!(cmd.output().unwrap().status.success()); @@ -60,15 +59,59 @@ export PATH="$HOME/apple/bin" let env = rcs.next().unwrap(); let envfile = fs::read_to_string(env).unwrap(); let (_, envfile_export) = envfile.split_at(envfile.find("export PATH").unwrap_or(0)); - assert_eq!(&envfile_export[..DEFAULT_EXPORT.len()], DEFAULT_EXPORT); + let expected_export = format!( + "export PATH=\"{}/.cargo/bin:$PATH\"\n", + cx.config.homedir.display() + ); + assert!(envfile_export.starts_with(&expected_export)); for rc in rcs { - let expected = source("$HOME/.cargo", POSIX_SH); + let expected = source(cx.config.homedir.join(".cargo").display(), POSIX_SH); let new_profile = fs::read_to_string(rc).unwrap(); assert_eq!(new_profile, expected); } } + #[tokio::test] + async fn category_mode_uses_rustup_homes_for_path_setup() { + let cx = CliTestContext::new(Scenario::Empty).await; + let bin_home = cx.config.homedir.join(".local/bin"); + let config_home = cx.config.homedir.join(".config/rustup"); + let profile = cx.config.homedir.join(".profile"); + raw::write_file(&profile, FAKE_RC).unwrap(); + + let mut cmd = cx.config.cmd("rustup-init", &INIT_NONE[1..]); + cmd.env("RUSTUP_USE_CATEGORY_HOME", "1"); + cmd.env("RUSTUP_BIN_HOME", &bin_home); + cmd.env("RUSTUP_CONFIG_HOME", &config_home); + assert!(cmd.output().unwrap().status.success()); + + assert!(bin_home.join("rustup").is_file()); + assert!(!cx.config.cargodir.join("bin/rustup").exists()); + let env_file = config_home.join("env"); + let source_env = |path: &OsStr| { + Command::new("/bin/sh") + .arg("-c") + .arg(format!(r#". "{}"; printf %s "$PATH""#, env_file.display())) + .env("PATH", path) + .output() + .unwrap() + }; + let output = source_env(OsStr::new("/usr/bin")); + assert!(output.status.success()); + let expected_path = env::join_paths([bin_home.clone(), PathBuf::from("/usr/bin")]).unwrap(); + assert_eq!(output.stdout, expected_path.as_encoded_bytes()); + + let existing_path = env::join_paths([PathBuf::from("/usr/bin"), bin_home.clone()]).unwrap(); + let output = source_env(&existing_path); + assert!(output.status.success()); + assert_eq!(output.stdout, existing_path.as_encoded_bytes()); + assert_eq!( + fs::read_to_string(profile).unwrap(), + FAKE_RC.to_owned() + &source(config_home.display(), POSIX_SH) + ); + } + #[tokio::test] async fn install_updates_bash_rcs() { let cx = CliTestContext::new(Scenario::Empty).await; @@ -149,12 +192,7 @@ error: could not amend shell profile[..] #[tokio::test] async fn install_with_zdotdir_from_calling_zsh() { // This test requires that zsh is callable. - if std::process::Command::new("zsh") - .arg("-c") - .arg("true") - .status() - .is_err() - { + if Command::new("zsh").arg("-c").arg("true").status().is_err() { return; } @@ -269,6 +307,35 @@ error: could not amend shell profile[..] } } + #[tokio::test] + async fn custom_cargo_home_preserves_legacy_source_and_cleans_up() { + let cx = CliTestContext::new(Scenario::Empty).await; + let cargo_home = cx.config.homedir.join("custom cargo"); + let profile = cx.config.homedir.join(".profile"); + // Freeze an old installation's absolute source command before reinstalling. + let expected = format!("{FAKE_RC}. \"{}/env\"\n", cargo_home.display()); + raw::write_file(&profile, &expected).unwrap(); + + let mut cmd = cx.config.cmd("rustup-init", &INIT_NONE[1..]); + cmd.env("CARGO_HOME", &cargo_home); + assert!(cmd.output().unwrap().status.success()); + assert_eq!(fs::read_to_string(&profile).unwrap(), expected); + assert!( + fs::read_to_string(cargo_home.join("env")) + .unwrap() + .contains(&format!( + "export PATH=\"{}/bin:$PATH\"", + cargo_home.display() + )) + ); + + let mut cmd = cx.config.cmd("rustup", ["self", "uninstall", "-y"]); + cmd.env("CARGO_HOME", &cargo_home); + assert!(cmd.output().unwrap().status.success()); + assert!(!cargo_home.join("env").exists()); + assert_eq!(fs::read_to_string(profile).unwrap(), FAKE_RC); + } + #[tokio::test] async fn uninstall_keeps_source_in_rcs_when_cargo_bin_is_non_empty() { let cx = CliTestContext::new(Scenario::Empty).await; @@ -373,7 +440,8 @@ error: could not amend shell profile[..] cmd.env("ZDOTDIR", zdotdir.path()); cmd.env_remove("CARGO_HOME"); assert!(cmd.output().unwrap().status.success()); - let fixed_rc = FAKE_RC.to_owned() + &source("$HOME/.cargo", POSIX_SH); + let fixed_rc = + FAKE_RC.to_owned() + &source(cx.config.homedir.join(".cargo").display(), POSIX_SH); for rc in &rcs { let new_rc = fs::read_to_string(rc).unwrap(); assert_eq!(new_rc, fixed_rc); @@ -396,7 +464,8 @@ error: could not amend shell profile[..] assert!(cmd.output().unwrap().status.success()); let new_profile = fs::read_to_string(&profile).unwrap(); - let expected = guarded_source.to_owned() + &source("$HOME/.cargo", POSIX_SH); + let expected = guarded_source.to_owned() + + &source(cx.config.homedir.join(".cargo").display(), POSIX_SH); assert_eq!(new_profile, expected); } @@ -458,31 +527,27 @@ error: could not amend shell profile[..] } } - // In the default case we want to write $HOME/.cargo/bin as the path, - // not the full path. #[tokio::test] - async fn when_cargo_home_is_the_default_write_path_specially() { + async fn default_cargo_home_recognizes_legacy_sources_and_cleans_up() { let cx = CliTestContext::new(Scenario::Empty).await; - // Override the test harness so that cargo home looks like - // $HOME/.cargo by removing CARGO_HOME from the environment, - // otherwise the literal path will be written to the file. - let profile = cx.config.homedir.join(".profile"); - raw::write_file(&profile, FAKE_RC).unwrap(); + let legacy = format!("{FAKE_RC}. \"$HOME/.cargo/env\"\n"); + raw::write_file(&profile, &legacy).unwrap(); + let mut cmd = cx.config.cmd("rustup-init", &INIT_NONE[1..]); + // Remove the test harness override to exercise HOME/.cargo. cmd.env_remove("CARGO_HOME"); assert!(cmd.output().unwrap().status.success()); + // Recognize the old command instead of adding its absolute equivalent. + assert_eq!(fs::read_to_string(&profile).unwrap(), legacy); - let new_profile = fs::read_to_string(&profile).unwrap(); - let expected = format!("{FAKE_RC}. \"$HOME/.cargo/env\"\n"); - assert_eq!(new_profile, expected); - + // Cleanup must remove both historical and current commands if both exist. + let both = legacy + &format!(". \"{}/.cargo/env\"\n", cx.config.homedir.display()); + raw::write_file(&profile, &both).unwrap(); let mut cmd = cx.config.cmd("rustup", ["self", "uninstall", "-y"]); cmd.env_remove("CARGO_HOME"); assert!(cmd.output().unwrap().status.success()); - - let new_profile = fs::read_to_string(&profile).unwrap(); - assert_eq!(new_profile, FAKE_RC); + assert_eq!(fs::read_to_string(&profile).unwrap(), FAKE_RC); } #[tokio::test] diff --git a/tests/suite/cli_rustup.rs b/tests/suite/cli_rustup.rs index 459a0b21d6..115de95b91 100644 --- a/tests/suite/cli_rustup.rs +++ b/tests/suite/cli_rustup.rs @@ -768,6 +768,11 @@ custom #[tokio::test] async fn fallback_cargo_calls_correct_rustc() { let cx = CliTestContext::new(Scenario::SimpleV2).await; + let data_home = cx.config.current_dir().join("data"); + let split_home_env = [ + ("RUSTUP_DATA_HOME", data_home.to_str().unwrap()), + ("RUSTUP_USE_CATEGORY_HOME", "1"), + ]; // Hm, this is the _only_ test that assumes that toolchain proxies // exist in CARGO_HOME. Adding that proxy here. let rustup_path = cx.config.exedir.join(format!("rustup{EXE_SUFFIX}")); @@ -780,19 +785,22 @@ async fn fallback_cargo_calls_correct_rustc() { let path = cx.config.customdir.join("custom-1"); let path = path.to_string_lossy(); cx.config - .expect(["rustup", "toolchain", "link", "custom", &path]) + .expect_with_env( + ["rustup", "toolchain", "link", "custom", &path], + split_home_env, + ) .await .is_ok(); cx.config - .expect(["rustup", "default", "custom"]) + .expect_with_env(["rustup", "default", "custom"], split_home_env) .await .is_ok(); cx.config - .expect(["rustup", "update", "nightly"]) + .expect_with_env(["rustup", "update", "nightly"], split_home_env) .await .is_ok(); cx.config - .expect(["rustc", "--version"]) + .expect_with_env(["rustc", "--version"], split_home_env) .await .with_stdout(snapbox::str![[r#" 1.0.0 (hash-c-1) @@ -800,7 +808,7 @@ async fn fallback_cargo_calls_correct_rustc() { "#]]) .is_ok(); cx.config - .expect(["cargo", "--version"]) + .expect_with_env(["cargo", "--version"], split_home_env) .await .with_stdout(snapbox::str![[r#" 1.3.0 (hash-nightly-2) @@ -815,13 +823,19 @@ async fn fallback_cargo_calls_correct_rustc() { // RUSTUP_TOOLCHAIN variable set by the original "cargo" proxy, and // interpreted by the nested "rustc" proxy. cx.config - .expect(["cargo", "--call-rustc"]) + .expect_with_env(["cargo", "--call-rustc"], split_home_env) .await .with_stdout(snapbox::str![[r#" 1.0.0 (hash-c-1) "#]]) .is_ok(); + + #[cfg(windows)] + { + assert!(data_home.join("fallback/cargo.exe").is_file()); + assert!(!cx.config.rustupdir.has("fallback/cargo.exe")); + } } // Checks that cargo can recursively invoke itself with rustup shorthand (via @@ -891,6 +905,176 @@ error: infinite recursion detected .is_err(); } +#[tokio::test] +async fn category_child_preserves_legacy_home_without_resolving_it() { + let cx = CliTestContext::new(Scenario::SimpleV2).await; + cx.config + .expect(["rustup", "default", "stable"]) + .await + .is_ok(); + for legacy in [Some("relative-legacy"), None] { + let mut cmd = cx.config.cmd("rustc", ["--echo-env", "RUSTUP_HOME"]); + cmd.env("RUSTUP_USE_CATEGORY_HOME", "1"); + for category in ["CONFIG", "CACHE", "DATA", "STATE"] { + cmd.env( + format!("RUSTUP_{category}_HOME"), + cx.config.rustupdir.to_string(), + ); + } + match legacy { + Some(value) => { + cmd.env("RUSTUP_HOME", value); + } + None => { + cmd.env_remove("RUSTUP_HOME"); + } + } + let output = cmd.output().unwrap(); + let stderr = String::from_utf8(output.stderr).unwrap(); + match legacy { + Some(value) => { + assert!(output.status.success(), "{stderr}"); + assert_eq!(stderr.trim(), value); + } + None => { + assert!(!output.status.success()); + assert!( + stderr.contains("RUSTUP_HOME environment variable not set"), + "{stderr}" + ); + } + } + } +} + +#[tokio::test] +async fn child_cargo_home_preserves_legacy_compatibility() { + let cx = CliTestContext::new(Scenario::SimpleV2).await; + cx.config + .expect(["rustup", "default", "stable"]) + .await + .is_ok(); + + for (mode, cargo_home, expected) in [ + ("0", None, Some(cx.config.homedir.join(".cargo"))), + ("1", None, None), + ("1", Some(""), Some(PathBuf::new())), + ( + "1", + Some("relative-cargo"), + Some(cx.config.current_dir().join("relative-cargo")), + ), + ] { + let mut cmd = cx.config.cmd("rustc", ["--echo-env", "CARGO_HOME"]); + cmd.env("RUSTUP_USE_CATEGORY_HOME", mode); + match cargo_home { + Some(value) => { + cmd.env("CARGO_HOME", value); + } + None => { + cmd.env_remove("CARGO_HOME"); + } + } + let output = cmd.output().unwrap(); + let stderr = String::from_utf8(output.stderr).unwrap(); + match expected { + Some(path) => { + assert!( + output.status.success(), + "mode={mode}, CARGO_HOME={cargo_home:?}: {stderr}" + ); + assert_eq!( + stderr.trim(), + path.to_string_lossy(), + "mode={mode}, CARGO_HOME={cargo_home:?}" + ); + } + None => { + assert!( + !output.status.success(), + "mode={mode}, CARGO_HOME={cargo_home:?}: {stderr}" + ); + assert!( + stderr.contains("CARGO_HOME environment variable not set"), + "{stderr}" + ); + } + } + } +} + +#[tokio::test] +async fn show_category_homes() { + let cx = CliTestContext::new(Scenario::None).await; + let dirs = tempfile::tempdir().unwrap(); + let categories = ["config", "cache", "data", "state", "bin"]; + let configure = |cmd: &mut std::process::Command| { + cmd.env("RUSTUP_USE_CATEGORY_HOME", "1"); + for category in categories { + cmd.env( + format!("RUSTUP_{}_HOME", category.to_uppercase()), + dirs.path().join(format!("{category} home")), + ); + } + }; + let mut cmd = cx.config.cmd("rustup", ["show", "home"]); + configure(&mut cmd); + let output = cmd.output().unwrap(); + assert!(output.status.success(), "{output:?}"); + let expected = ["config", "state", "data", "cache"] + .map(|category| { + format!( + "{category}: {}\n", + dirs.path().join(format!("{category} home")).display() + ) + }) + .concat(); + assert_eq!(String::from_utf8(output.stdout).unwrap(), expected); + + let mut cmd = cx.config.cmd("rustup", ["show"]); + configure(&mut cmd); + let output = cmd.output().unwrap(); + assert!(output.status.success(), "{output:?}"); + let stdout = String::from_utf8(output.stdout).unwrap(); + let start = stdout.find("rustup homes:").unwrap(); + let expected = ["config", "state", "data", "cache", "bin"] + .map(|category| { + format!( + " {category}: {}\n", + dirs.path().join(format!("{category} home")).display() + ) + }) + .concat(); + assert!( + stdout[start..].starts_with(&format!("rustup homes:\n{expected}")), + "{stdout}" + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn show_category_platform_defaults() { + let cx = CliTestContext::new(Scenario::None).await; + let mut cmd = cx.config.cmd("rustup", ["show", "home"]); + cmd.env("RUSTUP_USE_CATEGORY_HOME", "1") + .env_remove("RUSTUP_HOME"); + for name in ["CONFIG", "CACHE", "DATA", "STATE", "BIN"] { + cmd.env_remove(format!("RUSTUP_{name}_HOME")) + .env_remove(format!("XDG_{name}_HOME")); + } + let output = cmd.output().unwrap(); + assert!(output.status.success(), "{output:?}"); + let expected = [ + ("config", ".config/rustup"), + ("state", ".local/state/rustup"), + ("data", ".local/share/rustup"), + ("cache", ".cache/rustup"), + ] + .map(|(category, subdir)| format!("{category}: {}\n", cx.config.homedir.join(subdir).display())) + .concat(); + assert_eq!(String::from_utf8(output.stdout).unwrap(), expected); +} + #[tokio::test] async fn show_home() { let cx = CliTestContext::new(Scenario::None).await; @@ -1063,18 +1247,24 @@ installed targets: } #[tokio::test] -async fn notify_release_hint_at_most_once_per_day() { +async fn notify_release_hint_uses_state_home_at_most_once_per_day() { let cx = CliTestContext::new(Scenario::SimpleV2).await; + let state_home = cx.config.current_dir().join("relative/state"); + let state_home_env = state_home.to_str().unwrap(); + let state_env = [ + ("RUSTUP_STATE_HOME", state_home_env), + ("RUSTUP_USE_CATEGORY_HOME", "1"), + ]; cx.config - .expect(["rustup", "set", "release-hint", "enable"]) + .expect_with_env(["rustup", "set", "release-hint", "enable"], state_env) .await .is_ok(); cx.config - .expect(["rustup", "update", "stable"]) + .expect_with_env(["rustup", "update", "stable"], state_env) .await .is_ok(); cx.config - .expect(["rustup", "show"]) + .expect_with_env(["rustup", "show"], state_env) .await .with_stderr(snapbox::str![[r#" hint: a new stable Rust release is available, run `rustup update stable` to install it @@ -1082,10 +1272,22 @@ hint: a new stable Rust release is available, run `rustup update stable` to inst "#]]) .is_ok(); cx.config - .expect(["rustup", "show"]) + .expect_with_env(["rustup", "show"], state_env) .await .with_stderr(snapbox::str![[""]]) .is_ok(); + assert!(state_home.join("state.toml").is_file()); + assert!(!cx.config.rustupdir.has("state.toml")); + + let rustc = cx + .config + .expect_with_env( + ["rustc", "+stable", "--echo-env", "RUSTUP_STATE_HOME"], + state_env, + ) + .await; + rustc.is_ok(); + assert_eq!(rustc.output.stderr.trim(), state_home.to_string_lossy()); } #[tokio::test] diff --git a/tests/suite/cli_rustup_ui/rustup_show_cmd_help_flag.stdout.term.svg b/tests/suite/cli_rustup_ui/rustup_show_cmd_help_flag.stdout.term.svg index 59c4ce8314..dd6c448fc0 100644 --- a/tests/suite/cli_rustup_ui/rustup_show_cmd_help_flag.stdout.term.svg +++ b/tests/suite/cli_rustup_ui/rustup_show_cmd_help_flag.stdout.term.svg @@ -32,7 +32,7 @@ active-toolchain Show the active toolchain - home Display the computed value of RUSTUP_HOME + home Display resolved Rustup home directories profile Show the default profile used for the `rustup install` command diff --git a/tests/suite/cli_rustup_ui/rustup_show_cmd_home_cmd_help_flag.stdout.term.svg b/tests/suite/cli_rustup_ui/rustup_show_cmd_home_cmd_help_flag.stdout.term.svg index 1c9515f722..81cd67043d 100644 --- a/tests/suite/cli_rustup_ui/rustup_show_cmd_home_cmd_help_flag.stdout.term.svg +++ b/tests/suite/cli_rustup_ui/rustup_show_cmd_home_cmd_help_flag.stdout.term.svg @@ -19,7 +19,7 @@ - Display the computed value of RUSTUP_HOME + Display resolved Rustup home directories diff --git a/tests/suite/cli_self_upd.rs b/tests/suite/cli_self_upd.rs index 83ca66b671..a30a751088 100644 --- a/tests/suite/cli_self_upd.rs +++ b/tests/suite/cli_self_upd.rs @@ -252,6 +252,282 @@ async fn uninstall_works_if_some_bins_dont_exist() { assert!(!rust_gdbgui.exists()); } +#[cfg(unix)] +#[tokio::test] +async fn category_uninstall_cleans_shell_sources_when_bin_homes_are_non_empty() { + let cx = setup_empty_installed().await; + let dirs = tempfile::tempdir().unwrap(); + let config_home = dirs.path().join("config home"); + let category_bin = dirs.path().join("category bin"); + let legacy_bin = cx.config.cargodir.join("bin"); + fs::create_dir_all(&config_home).unwrap(); + fs::write(config_home.join("env"), "# category environment\n").unwrap(); + fs::create_dir_all(&category_bin).unwrap(); + fs::copy(legacy_bin.join("rustup"), category_bin.join("rustup")).unwrap(); + let category_custom_tool = category_bin.join("custom-tool"); + let legacy_custom_tool = legacy_bin.join("custom-tool"); + fs::write(&category_custom_tool, "user binary").unwrap(); + fs::write(&legacy_custom_tool, "user binary").unwrap(); + + let profile = cx.config.homedir.join(".profile"); + let original = format!( + "# keep this line\n. \"{}/env\"\n. \"{}/env\"\n", + config_home.display(), + cx.config.cargodir.display() + ); + fs::write(&profile, original).unwrap(); + + let mut cmd = cx.config.cmd("rustup", ["self", "uninstall", "-y"]); + cmd.env("RUSTUP_USE_CATEGORY_HOME", "1") + .env("RUSTUP_BIN_HOME", &category_bin) + .env("RUSTUP_CONFIG_HOME", &config_home); + let output = cmd.output().unwrap(); + + assert!(output.status.success(), "{output:?}"); + assert_eq!(fs::read_to_string(profile).unwrap(), "# keep this line\n"); + assert!(category_custom_tool.exists()); + assert!(legacy_custom_tool.exists()); +} + +#[cfg(unix)] +#[tokio::test] +async fn category_uninstall_doesnt_modify_shell_sources_with_no_modify_path() { + let cx = setup_empty_installed().await; + let dirs = tempfile::tempdir().unwrap(); + let config_home = dirs.path().join("config home"); + fs::create_dir_all(&config_home).unwrap(); + + let profile = cx.config.homedir.join(".profile"); + let original = format!( + ". \"{}/env\"\n. \"{}/env\"\n", + config_home.display(), + cx.config.cargodir.display() + ); + fs::write(&profile, &original).unwrap(); + + let mut cmd = cx + .config + .cmd("rustup", ["self", "uninstall", "-y", "--no-modify-path"]); + cmd.env("RUSTUP_USE_CATEGORY_HOME", "1") + .env("RUSTUP_CONFIG_HOME", &config_home); + let output = cmd.output().unwrap(); + + assert!(output.status.success(), "{output:?}"); + assert_eq!(fs::read_to_string(profile).unwrap(), original); +} + +#[cfg(windows)] +#[tokio::test] +async fn category_uninstall_updates_path_per_bin() { + use rustup::test::USER_PATH; + use windows_registry::HSTRING; + + let cx = setup_empty_installed().await; + let legacy_bin = cx.config.cargodir.join("bin"); + let category_bin = cx.config.homedir.join("category bin"); + let legacy_rustup = legacy_bin.join("rustup.exe"); + let custom_tool = legacy_bin.join("custom.exe"); + fs::create_dir_all(&category_bin).unwrap(); + fs::copy(&legacy_rustup, category_bin.join("rustup.exe")).unwrap(); + fs::write(&custom_tool, "user binary").unwrap(); + + let before = format!( + "C:\\unrelated;{};{}", + legacy_bin.display(), + category_bin.display() + ); + USER_PATH + .set( + Some(&Value::from(before.as_str())), + &cx.config.test_registry_id, + CURRENT_USER, + ) + .unwrap(); + + let mut cmd = cx.config.cmd("rustup", ["self", "uninstall", "-y"]); + cmd.env("RUSTUP_USE_CATEGORY_HOME", "1") + .env("RUSTUP_BIN_HOME", &category_bin); + let output = cmd.output().unwrap(); + assert!(output.status.success(), "{output:?}"); + + let expected = format!("C:\\unrelated;{}", legacy_bin.display()); + retry(Fibonacci::from_millis(1).map(jitter).take(23), || { + let value = USER_PATH + .get(&cx.config.test_registry_id, CURRENT_USER) + .unwrap() + .unwrap(); + let actual = HSTRING::try_from(value).unwrap().to_string_lossy(); + let category_exists = category_bin.exists(); + let rustup_exists = legacy_rustup.exists(); + let custom_exists = custom_tool.exists(); + if actual == expected && !category_exists && !rustup_exists && custom_exists { + Ok(()) + } else { + Err(format!( + "PATH: expected {expected:?}, got {actual:?}; \ + category_exists={category_exists}, \ + legacy_rustup_exists={rustup_exists}, \ + custom_exists={custom_exists}" + )) + } + }) + .unwrap(); +} + +#[cfg(windows)] +#[tokio::test] +async fn category_uninstall_preserves_path_with_no_modify_path() { + use rustup::test::USER_PATH; + use windows_registry::HSTRING; + + let cx = setup_empty_installed().await; + let legacy_bin = cx.config.cargodir.join("bin"); + let category_bin = cx.config.homedir.join("category bin"); + fs::create_dir_all(&category_bin).unwrap(); + fs::copy( + legacy_bin.join("rustup.exe"), + category_bin.join("rustup.exe"), + ) + .unwrap(); + + let before = format!( + "C:\\unrelated;{};{}", + legacy_bin.display(), + category_bin.display() + ); + USER_PATH + .set( + Some(&Value::from(before.as_str())), + &cx.config.test_registry_id, + CURRENT_USER, + ) + .unwrap(); + + let mut cmd = cx + .config + .cmd("rustup", ["self", "uninstall", "-y", "--no-modify-path"]); + cmd.env("RUSTUP_USE_CATEGORY_HOME", "1") + .env("RUSTUP_BIN_HOME", &category_bin); + let output = cmd.output().unwrap(); + assert!(output.status.success(), "{output:?}"); + + retry(Fibonacci::from_millis(1).map(jitter).take(23), || { + let value = USER_PATH + .get(&cx.config.test_registry_id, CURRENT_USER) + .unwrap() + .unwrap(); + let actual = HSTRING::try_from(value).unwrap().to_string_lossy(); + let legacy_exists = legacy_bin.exists(); + let category_exists = category_bin.exists(); + if actual == before && !legacy_exists && !category_exists { + Ok(()) + } else { + Err(format!( + "PATH: expected {before:?}, got {actual:?}; \ + legacy_exists={legacy_exists}, category_exists={category_exists}" + )) + } + }) + .unwrap(); +} + +#[tokio::test] +async fn uninstall_deletes_category_only_binaries() { + let cx = setup_empty_installed().await; + let legacy_bin = cx.config.cargodir.join("bin"); + let category_bin = cx.config.homedir.join("category-bin"); + fs::create_dir_all(&category_bin).unwrap(); + + let rustup_exe = format!("rustup{EXE_SUFFIX}"); + let legacy_rustup = legacy_bin.join(&rustup_exe); + let category_rustup = category_bin.join(&rustup_exe); + let category_proxy = category_bin.join(format!("rustc{EXE_SUFFIX}")); + fs::copy(&legacy_rustup, &category_rustup).unwrap(); + fs::hard_link(&category_rustup, &category_proxy).unwrap(); + remove_dir_all(&cx.config.cargodir).unwrap(); + + let mut cmd = cx + .config + .cmd("rustup", ["self", "uninstall", "-y", "--no-modify-path"]); + cmd.env("RUSTUP_USE_CATEGORY_HOME", "1"); + cmd.env("RUSTUP_BIN_HOME", &category_bin); + + assert!(cmd.output().unwrap().status.success()); + let removed_paths = [&category_rustup, &category_proxy, &category_bin]; + #[cfg(unix)] + for path in removed_paths { + assert!(!path.exists(), "path still exists: {}", path.display()); + } + #[cfg(windows)] + retry(Fibonacci::from_millis(1).map(jitter).take(23), || { + if let Some(path) = removed_paths.iter().find(|path| path.exists()) { + Err(format!("path still exists: {}", path.display())) + } else { + Ok(()) + } + }) + .unwrap(); +} + +#[tokio::test] +async fn uninstall_deletes_legacy_and_category_binaries() { + let cx = setup_empty_installed().await; + let legacy_bin = cx.config.cargodir.join("bin"); + let category_bin = cx.config.cargodir.join("category-bin"); + fs::create_dir_all(&category_bin).unwrap(); + + let rustup_exe = format!("rustup{EXE_SUFFIX}"); + let legacy_rustup = legacy_bin.join(&rustup_exe); + let category_rustup = category_bin.join(&rustup_exe); + let category_proxy = category_bin.join(format!("rustc{EXE_SUFFIX}")); + let custom_tool = category_bin.join("custom-tool"); + fs::copy(&legacy_rustup, &category_rustup).unwrap(); + fs::hard_link(&category_rustup, &category_proxy).unwrap(); + fs::write(&custom_tool, "").unwrap(); + + let mut cmd = cx + .config + .cmd("rustup", ["self", "uninstall", "-y", "--no-modify-path"]); + cmd.env("RUSTUP_USE_CATEGORY_HOME", "1"); + cmd.env("RUSTUP_BIN_HOME", &category_bin); + + assert!(cmd.output().unwrap().status.success()); + let removed_paths = [&legacy_rustup, &category_rustup, &category_proxy]; + #[cfg(not(windows))] + for path in removed_paths { + assert!(!path.exists(), "path still exists: {}", path.display()); + } + #[cfg(windows)] + retry(Fibonacci::from_millis(1).map(jitter).take(23), || { + if let Some(path) = removed_paths.iter().find(|path| path.exists()) { + Err(format!("path still exists: {}", path.display())) + } else { + Ok(()) + } + }) + .unwrap(); + assert!(custom_tool.exists()); + assert!(category_bin.exists()); +} + +#[cfg(unix)] +#[tokio::test] +async fn uninstall_reuses_paths_after_removing_command_cwd() { + let mut cx = setup_empty_installed().await; + let removed_cwd = cx.config.cargodir.join("removed-cwd"); + fs::create_dir_all(&removed_cwd).unwrap(); + let cx = cx.change_dir(&removed_cwd); + + let mut cmd = cx + .config + .cmd("rustup", ["self", "uninstall", "-y", "--no-modify-path"]); + cmd.env("RUSTUP_USE_CATEGORY_HOME", "1"); + cmd.env("RUSTUP_CACHE_HOME", &removed_cwd); + + assert!(cmd.output().unwrap().status.success()); + assert!(!cx.config.cargodir.exists()); +} + #[tokio::test] async fn uninstall_deletes_rustup_home() { let cx = setup_empty_installed().await; @@ -262,6 +538,37 @@ async fn uninstall_deletes_rustup_home() { assert!(!cx.config.rustupdir.has(".")); } +#[tokio::test] +async fn uninstall_deletes_split_rustup_homes() { + let cx = setup_empty_installed().await; + let split_home = cx.config.homedir.join("split-home"); + let homes = [ + ("RUSTUP_CACHE_HOME", split_home.join("cache")), + ("RUSTUP_CONFIG_HOME", split_home.join("config")), + ("RUSTUP_DATA_HOME", split_home.join("data")), + ("RUSTUP_STATE_HOME", split_home.join("state")), + ]; + + for (_, home) in &homes { + fs::create_dir_all(home).unwrap(); + fs::write(home.join("marker"), "").unwrap(); + } + + let mut cmd = cx + .config + .cmd("rustup", ["self", "uninstall", "-y", "--no-modify-path"]); + cmd.env("RUSTUP_USE_CATEGORY_HOME", "1"); + for (variable, home) in &homes { + cmd.env(variable, home); + } + + assert!(cmd.output().unwrap().status.success()); + assert!(!cx.config.rustupdir.has(".")); + for (_, home) in homes { + assert!(!home.exists()); + } +} + #[tokio::test] async fn uninstall_works_if_rustup_home_doesnt_exist() { let cx = setup_empty_installed().await; @@ -380,54 +687,42 @@ async fn uninstall_self_delete_works() { } // On windows rustup self uninstall temporarily puts a rustup-gc-$randomnumber.exe -// file in CONFIG.CARGODIR/.. ; check that it doesn't exist. +// file in the system temporary directory; check that it is cleaned up. #[tokio::test] #[cfg(windows)] async fn uninstall_doesnt_leave_gc_file() { let cx = setup_empty_installed().await; + let gc_dir = tempfile::tempdir().unwrap(); + let gc_path = gc_dir.path().to_str().unwrap(); cx.config - .expect(["rustup", "self", "uninstall", "-y"]) + .expect_with_env( + ["rustup", "self", "uninstall", "-y"], + [("TMP", gc_path), ("TEMP", gc_path), ("SystemTemp", gc_path)], + ) .await .is_ok(); - let parent = cx.config.cargodir.parent().unwrap(); // The gc removal happens after rustup terminates. Typically under // 100ms, but during the contention of test suites can be substantially // longer while still succeeding. - let check = || ensure_empty(parent); + let check = || { + let garbage = fs::read_dir(gc_dir.path()) + .unwrap() + .map(|entry| entry.unwrap().path()) + .collect::>(); + if garbage.is_empty() { + Ok(()) + } else { + Err(format!("garbage remaining: {garbage:?}")) + } + }; match retry(Fibonacci::from_millis(1).map(jitter).take(23), check) { Ok(_) => (), Err(e) => panic!("{e}"), } } -#[cfg(windows)] -fn ensure_empty(dir: &Path) -> Result<(), GcErr> { - let garbage = fs::read_dir(dir) - .unwrap() - .filter_map(|entry| { - let path = entry.unwrap().path(); - let name = path.file_name()?.to_str()?; - // On Windows, this binary is cleaned up on exit - if !(name.starts_with("rustup-gc-") && name.ends_with(EXE_SUFFIX)) { - return None; - } - Some(path.to_string_lossy().to_string()) - }) - .collect::>(); - if garbage.is_empty() { - Ok(()) - } else { - Err(GcErr(garbage)) - } -} - -#[derive(thiserror::Error, Debug)] -#[error("garbage remaining: {:?}", .0)] -#[cfg(windows)] -struct GcErr(Vec); - #[tokio::test] async fn update_exact() { let cx = SelfUpdateTestContext::new(TEST_VERSION).await; @@ -525,7 +820,7 @@ async fn update_but_not_installed() { .is_err() .with_stdout(snapbox::str![[""]]) .with_stderr(snapbox::str![[r#" -error: rustup is not installed at '[CARGO_DIR]' +error: rustup is not installed at '[CARGO_DIR]/bin' "#]]); } diff --git a/tests/suite/cli_v2.rs b/tests/suite/cli_v2.rs index 6d8ae541a7..96557f995b 100644 --- a/tests/suite/cli_v2.rs +++ b/tests/suite/cli_v2.rs @@ -1960,6 +1960,99 @@ warn: removing the last target; no build targets will be available .is_ok(); } +#[tokio::test] +async fn install_uses_cache_home_and_forwards_it() { + let cx = CliTestContext::new(Scenario::SimpleV2).await; + let cache_home = cx.config.current_dir().join("relative/cache"); + let cache_home_env = cache_home.to_str().unwrap(); + let toolchain = format!("stable-{}", this_host_tuple()); + + cx.config + .expect_with_env( + ["rustup", "toolchain", "install", "stable"], + [ + ("RUSTUP_CACHE_HOME", cache_home_env), + ("RUSTUP_USE_CATEGORY_HOME", "1"), + ], + ) + .await + .is_ok(); + + assert!(cache_home.join("tmp").is_dir()); + assert!(!cx.config.rustupdir.has("tmp")); + assert!(cache_home.join("downloads").is_dir()); + assert!(!cx.config.rustupdir.has("downloads")); + + assert!(cache_home.join("update-hashes").join(&toolchain).is_file()); + assert!( + !cx.config + .rustupdir + .has(format!("update-hashes/{toolchain}")) + ); + assert!(cx.config.rustupdir.has(format!("toolchains/{toolchain}"))); + + let rustc = cx + .config + .expect_with_env( + ["rustc", "+stable", "--echo-env", "RUSTUP_CACHE_HOME"], + [ + ("RUSTUP_CACHE_HOME", cache_home_env), + ("RUSTUP_USE_CATEGORY_HOME", "1"), + ], + ) + .await; + rustc.is_ok(); + assert_eq!(rustc.output.stderr.trim(), cache_home.to_string_lossy()); + + cx.config + .expect_with_env( + ["rustup", "toolchain", "remove", "stable"], + [ + ("RUSTUP_CACHE_HOME", cache_home_env), + ("RUSTUP_USE_CATEGORY_HOME", "1"), + ], + ) + .await + .is_ok(); + assert!(!cache_home.join("update-hashes").join(&toolchain).is_file()); +} + +#[tokio::test] +async fn install_uses_data_home_and_forwards_it() { + let cx = CliTestContext::new(Scenario::SimpleV2).await; + let data_home = cx.config.current_dir().join("relative/data"); + let data_home_env = data_home.to_str().unwrap(); + let toolchain = format!("stable-{}", this_host_tuple()); + + cx.config + .expect_with_env( + ["rustup", "toolchain", "install", "stable"], + [ + ("RUSTUP_DATA_HOME", data_home_env), + ("RUSTUP_USE_CATEGORY_HOME", "1"), + ], + ) + .await + .is_ok(); + + assert!(data_home.join("toolchains").join(&toolchain).is_dir()); + assert!(!cx.config.rustupdir.has(format!("toolchains/{toolchain}"))); + + // Test `toolchain::set_env` forward to sub process is working + let rustc = cx + .config + .expect_with_env( + ["rustc", "+stable", "--echo-env", "RUSTUP_DATA_HOME"], + [ + ("RUSTUP_DATA_HOME", data_home_env), + ("RUSTUP_USE_CATEGORY_HOME", "1"), + ], + ) + .await; + rustc.is_ok(); + assert_eq!(rustc.output.stderr.trim(), data_home.to_string_lossy()); +} + #[tokio::test] // Issue #304 async fn remove_target_missing_update_hash() {