From 1d5a9d1befcf3b259156f26d5ebd94ea277c9f66 Mon Sep 17 00:00:00 2001 From: Cloud0310 <60375730+Cloud0310@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:33:22 +0800 Subject: [PATCH 01/19] refactor(self-update): clarify helper names --- src/cli/self_update.rs | 17 +++++----- src/cli/self_update/shell.rs | 58 ++++++++++++++++++---------------- src/cli/self_update/unix.rs | 12 +++---- src/cli/self_update/windows.rs | 10 +++--- 4 files changed, 49 insertions(+), 48 deletions(-) diff --git a/src/cli/self_update.rs b/src/cli/self_update.rs index 2f8b8ca69a..755e1fac98 100644 --- a/src/cli/self_update.rs +++ b/src/cli/self_update.rs @@ -83,7 +83,7 @@ mod shell; #[cfg(unix)] mod unix; #[cfg(unix)] -use unix::{do_add_to_path, do_remove_from_path}; +use unix::{add_to_path, remove_from_path}; #[cfg(unix)] pub(crate) use unix::{run_update, self_replace}; @@ -95,8 +95,7 @@ pub use windows::complete_windows_uninstall; pub use windows::{RUSTUP_REGISTRY_TEST_ID, RegistryValueId, USER_PATH, get_path}; #[cfg(windows)] use windows::{ - add_uninstall_registry_entry, do_add_to_path, do_remove_from_path, - remove_uninstall_registry_entry, + add_to_path, add_uninstall_registry_entry, remove_from_path, remove_uninstall_registry_entry, }; #[cfg(windows)] pub(crate) use windows::{run_update, self_replace}; @@ -147,7 +146,7 @@ impl InstallOpts<'_> { #[cfg(unix)] { - exit_code &= unix::do_anti_sudo_check(no_prompt, process)?; + exit_code &= unix::anti_sudo_check(no_prompt, process)?; } let mut term = process.stdout(); @@ -246,10 +245,10 @@ impl InstallOpts<'_> { install_bins(process)?; #[cfg(unix)] - unix::do_write_env_files(process)?; + unix::write_env_files(process)?; if !self.no_modify_path { - do_add_to_path(process)?; + add_to_path(process)?; } #[cfg(windows)] @@ -691,11 +690,11 @@ fn pre_install_msg(no_modify_path: bool, process: &Process) -> anyhow::Result>(); let plural = if rcfiles.len() > 1 { "s" } else { "" }; @@ -1065,7 +1064,7 @@ fn clean_cargo_home( } Ok(()) if !no_modify_path => { info!("removing cargo bin directory `{cargo_bin_display}` from $PATH"); - do_remove_from_path(process)?; + remove_from_path(process)?; } Ok(()) => {} } diff --git a/src/cli/self_update/shell.rs b/src/cli/self_update/shell.rs index 4e3e062923..38b037d5ec 100644 --- a/src/cli/self_update/shell.rs +++ b/src/cli/self_update/shell.rs @@ -113,10 +113,12 @@ pub(crate) trait UnixShell { // Gives all rcfiles of a given shell that Rustup is concerned with. // Used primarily in checking rcfiles for cleanup. - fn rcfiles(&self, process: &Process) -> Vec; + fn rc_candidates(&self, process: &Process) -> Vec; - // Gives rcs that should be written to. - fn update_rcs(&self, process: &Process) -> Vec; + // Returns rcfile paths where installation should add the source command. + // May return multiple paths, including files that do not yet exist. + // Does not modify the files. + fn rcs(&self, process: &Process) -> Vec; // Writes the relevant env file. fn env_script(&self) -> ShellScript { @@ -159,17 +161,17 @@ impl UnixShell for Posix { "sh/ash/dash/pdksh" } - fn rcfiles(&self, process: &Process) -> Vec { + fn rc_candidates(&self, process: &Process) -> Vec { match process.home_dir() { Some(dir) => vec![dir.join(".profile")], _ => vec![], } } - fn update_rcs(&self, process: &Process) -> Vec { + fn rcs(&self, process: &Process) -> Vec { // Write to .profile even if it doesn't exist. It's the only rc in the // POSIX spec so it should always be set up. - self.rcfiles(process) + self.rc_candidates(process) } } @@ -177,14 +179,14 @@ struct Bash; impl UnixShell for Bash { fn does_exist(&self, process: &Process) -> bool { - !self.update_rcs(process).is_empty() + !self.rcs(process).is_empty() } fn name(&self) -> &'static str { "bash" } - fn rcfiles(&self, process: &Process) -> Vec { + fn rc_candidates(&self, process: &Process) -> Vec { // Bash also may read .profile, however Rustup already includes handling // .profile as part of POSIX and always does setup for POSIX shells. [".bash_profile", ".bash_login", ".bashrc"] @@ -193,8 +195,8 @@ impl UnixShell for Bash { .collect() } - fn update_rcs(&self, process: &Process) -> Vec { - self.rcfiles(process) + fn rcs(&self, process: &Process) -> Vec { + self.rc_candidates(process) .into_iter() .filter(|rc| rc.is_file()) .collect() @@ -235,24 +237,24 @@ impl UnixShell for Zsh { "zsh" } - fn rcfiles(&self, process: &Process) -> Vec { + fn rc_candidates(&self, process: &Process) -> Vec { [Self::zdotdir(process).ok(), process.home_dir()] .iter() .filter_map(|dir| dir.as_ref().map(|p| p.join(".zshenv"))) .collect() } - fn update_rcs(&self, process: &Process) -> Vec { + fn rcs(&self, process: &Process) -> Vec { // zsh can change $ZDOTDIR both _before_ AND _during_ reading .zshenv, // so we: write to $ZDOTDIR/.zshenv if-exists ($ZDOTDIR changes before) // OR write to $HOME/.zshenv if it exists (change-during) // if neither exist, we create it ourselves, but using the same logic, // because we must still respond to whether $ZDOTDIR is set or unset. // In any case we only write once. - self.rcfiles(process) + self.rc_candidates(process) .into_iter() .filter(|env| env.is_file()) - .chain(self.rcfiles(process)) + .chain(self.rc_candidates(process)) .take(1) .collect() } @@ -273,7 +275,7 @@ impl UnixShell for Fish { // > "$XDG_CONFIG_HOME/fish/conf.d" (or "~/.config/fish/conf.d" if that variable is unset) for the user // from - fn rcfiles(&self, process: &Process) -> Vec { + fn rc_candidates(&self, process: &Process) -> Vec { let p0 = process.var("XDG_CONFIG_HOME").ok().map(|p| { let mut path = PathBuf::from(p); path.push("fish/conf.d/rustup.fish"); @@ -288,9 +290,9 @@ impl UnixShell for Fish { p0.into_iter().chain(p1).collect() } - fn update_rcs(&self, process: &Process) -> Vec { + fn rcs(&self, process: &Process) -> Vec { // The first rcfile takes precedence. - match self.rcfiles(process).into_iter().next() { + match self.rc_candidates(process).into_iter().next() { Some(path) => vec![path], None => vec![], } @@ -324,7 +326,7 @@ impl UnixShell for Nu { "nushell" } - fn rcfiles(&self, process: &Process) -> Vec { + fn rc_candidates(&self, process: &Process) -> Vec { let mut paths = vec![]; if let Ok(p) = process.var("XDG_CONFIG_HOME") { @@ -340,9 +342,9 @@ impl UnixShell for Nu { paths } - fn update_rcs(&self, process: &Process) -> Vec { + fn rcs(&self, process: &Process) -> Vec { // The first rcfile in XDG_CONFIG_HOME takes precedence. - match self.rcfiles(process).into_iter().next() { + match self.rc_candidates(process).into_iter().next() { Some(path) => vec![path], None => vec![], } @@ -379,7 +381,7 @@ impl UnixShell for Tcsh { "tcsh" } - fn rcfiles(&self, process: &Process) -> Vec { + fn rc_candidates(&self, process: &Process) -> Vec { let mut paths = vec![]; if let Some(home) = process.home_dir() { @@ -390,8 +392,8 @@ impl UnixShell for Tcsh { paths } - fn update_rcs(&self, process: &Process) -> Vec { - for f in self.rcfiles(process) { + fn rcs(&self, process: &Process) -> Vec { + for f in self.rc_candidates(process) { if f.is_file() { return vec![f]; } @@ -432,7 +434,7 @@ impl UnixShell for Pwsh { "pwsh" } - fn rcfiles(&self, process: &Process) -> Vec { + fn rc_candidates(&self, process: &Process) -> Vec { let mut paths = vec![]; let Some(mut config_dir) = process.home_dir() else { @@ -476,7 +478,7 @@ impl UnixShell for Pwsh { paths } - fn update_rcs(&self, process: &Process) -> Vec { + fn rcs(&self, process: &Process) -> Vec { let mut paths = vec![]; // Always modify the "Current User, All Hosts" profile. let Some(mut profile) = process.home_dir() else { @@ -511,7 +513,7 @@ impl UnixShell for Xonsh { "xonsh" } - fn rcfiles(&self, process: &Process) -> Vec { + fn rc_candidates(&self, process: &Process) -> Vec { let mut paths = vec![]; if let Ok(p) = process.var("XDG_CONFIG_HOME") { @@ -532,9 +534,9 @@ impl UnixShell for Xonsh { paths } - fn update_rcs(&self, process: &Process) -> Vec { + fn rcs(&self, process: &Process) -> Vec { // The first rcfile in XDG_CONFIG_HOME takes precedence. - match self.rcfiles(process).into_iter().next() { + match self.rc_candidates(process).into_iter().next() { Some(path) => vec![path], None => vec![], } diff --git a/src/cli/self_update/unix.rs b/src/cli/self_update/unix.rs index 5da3d78ba0..2ac6ddabcc 100644 --- a/src/cli/self_update/unix.rs +++ b/src/cli/self_update/unix.rs @@ -15,7 +15,7 @@ use crate::{process::Process, utils}; // If the user is trying to install with sudo, on some systems this will // result in writing root-owned files to the user's home directory, because // sudo is configured not to change $HOME. Don't let that bogosity happen. -pub(crate) fn do_anti_sudo_check( +pub(crate) fn anti_sudo_check( no_prompt: bool, process: &Process, ) -> anyhow::Result { @@ -53,12 +53,12 @@ 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 remove_from_path(process: &Process) -> anyhow::Result<()> { for sh in shell::get_available_shells(process) { let source_bytes = format!("{}\n", sh.source_string(process)?).into_bytes(); // Check more files for cleanup than normally are updated. - for rc in sh.rcfiles(process).iter().filter(|rc| rc.is_file()) { + for rc in sh.rc_candidates(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. @@ -77,12 +77,12 @@ pub(crate) fn do_remove_from_path(process: &Process) -> anyhow::Result<()> { Ok(()) } -pub(crate) fn do_add_to_path(process: &Process) -> anyhow::Result<()> { +pub(crate) fn add_to_path(process: &Process) -> anyhow::Result<()> { for sh in shell::get_available_shells(process) { let source_cmd = sh.source_string(process)?; let source_cmd_with_newline = format!("\n{source_cmd}"); - for rc in sh.update_rcs(process) { + for rc in sh.rcs(process) { let cmd_to_write = match utils::read_file("rcfile", &rc) { Ok(contents) if contents.contains(&source_cmd) => continue, Ok(contents) if !contents.ends_with('\n') => &source_cmd_with_newline, @@ -106,7 +106,7 @@ pub(crate) fn do_add_to_path(process: &Process) -> anyhow::Result<()> { Ok(()) } -pub(crate) fn do_write_env_files(process: &Process) -> anyhow::Result<()> { +pub(crate) fn write_env_files(process: &Process) -> anyhow::Result<()> { let mut written = vec![]; for sh in shell::get_available_shells(process) { diff --git a/src/cli/self_update/windows.rs b/src/cli/self_update/windows.rs index 0aff01b33c..230e267c9a 100644 --- a/src/cli/self_update/windows.rs +++ b/src/cli/self_update/windows.rs @@ -126,7 +126,7 @@ pub(super) async fn maybe_install_msvc( opts: &InstallOpts<'_>, process: &Process, ) -> anyhow::Result<()> { - let Some(plan) = do_msvc_check(opts, process) else { + let Some(plan) = msvc_check(opts, process) else { return Ok(()); }; @@ -211,7 +211,7 @@ pub(crate) enum VsInstallPlan { // Provide guidance about setting up MSVC if it doesn't appear to be // installed -pub(crate) fn do_msvc_check(opts: &InstallOpts<'_>, process: &Process) -> Option { +pub(crate) fn msvc_check(opts: &InstallOpts<'_>, process: &Process) -> Option { // Test suite skips this since it's env dependent if process.var("RUSTUP_INIT_SKIP_MSVC_CHECK").is_ok() { return None; @@ -354,7 +354,7 @@ pub(crate) async fn try_install_msvc( // It's possible that the installer returned a non-zero exit code // even though the required components were successfully installed. // In that case we warn about the error but continue on. - let have_msvc = do_msvc_check(opts, process).is_none(); + let have_msvc = msvc_check(opts, process).is_none(); let has_libs = has_windows_sdk_libs(process); if have_msvc && has_libs { warn!("Visual Studio is installed but a problem occurred during installation"); @@ -465,7 +465,7 @@ pub(crate) fn wait_for_parent() -> anyhow::Result<()> { Ok(()) } -pub(crate) fn do_add_to_path(process: &Process) -> anyhow::Result<()> { +pub(crate) fn add_to_path(process: &Process) -> anyhow::Result<()> { let new_path = _with_path_cargo_home_bin(_add_to_path, process)?; _apply_new_path(new_path, process) } @@ -570,7 +570,7 @@ where Ok(windows_path.and_then(|old_path| f(old_path, HSTRING::from(path_str.as_path())))) } -pub(crate) fn do_remove_from_path(process: &Process) -> anyhow::Result<()> { +pub(crate) fn remove_from_path(process: &Process) -> anyhow::Result<()> { let new_path = _with_path_cargo_home_bin(_remove_from_path, process)?; _apply_new_path(new_path, process) } From df487bb626f94986d174238882b6e62191c1e5d5 Mon Sep 17 00:00:00 2001 From: Cloud0310 <60375730+Cloud0310@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:33:32 +0800 Subject: [PATCH 02/19] refactor(shell): simplify shell representations --- src/cli/self_update/shell.rs | 54 +++++++++++++++++------------------- 1 file changed, 25 insertions(+), 29 deletions(-) diff --git a/src/cli/self_update/shell.rs b/src/cli/self_update/shell.rs index 38b037d5ec..3e2aaf9421 100644 --- a/src/cli/self_update/shell.rs +++ b/src/cli/self_update/shell.rs @@ -23,15 +23,13 @@ //! 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::PathBuf; use anyhow::bail; use super::utils; use crate::process::Process; -pub(crate) type Shell = Box; - #[derive(Debug, PartialEq)] pub(crate) struct ShellScript { content: &'static str, @@ -39,7 +37,7 @@ pub(crate) struct ShellScript { } // TODO: Update into a bytestring. -fn cargo_home_str_with_home(home: &str, process: &Process) -> anyhow::Result> { +fn cargo_home_str_with_home(home: &str, process: &Process) -> anyhow::Result { let path = process.cargo_home()?; let default_cargo_home = process @@ -47,31 +45,15 @@ fn cargo_home_str_with_home(home: &str, process: &Process) -> anyhow::Result p.to_owned().into(), + Some(p) => p.to_owned(), None => bail!("Non-Unicode path!"), } }) } -// TODO: Tcsh (BSD) -// TODO?: Make a decision on Ion Shell -// Cross-platform non-POSIX shells have not been assessed for integration yet -fn enumerate_shells() -> Vec { - vec![ - Box::new(Posix), - Box::new(Bash), - Box::new(Zsh), - Box::new(Fish), - Box::new(Nu), - Box::new(Tcsh), - Box::new(Pwsh), - Box::new(Xonsh), - ] -} - /// Builds the shell source lines for the post-install message, showing only /// 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`). @@ -97,10 +79,24 @@ pub(crate) fn build_source_env_lines(process: &Process) -> String { .collect() } -pub(crate) fn get_available_shells(process: &Process) -> impl Iterator + '_ { - enumerate_shells() - .into_iter() - .filter(|sh| sh.does_exist(process)) +// TODO: Tcsh (BSD) +// TODO?: Make a decision on Ion Shell +// Cross-platform non-POSIX shells have not been assessed for integration yet +pub(crate) fn get_available_shells( + process: &Process, +) -> impl Iterator + '_ { + [ + &Posix as &dyn UnixShell, + &Bash, + &Zsh, + &Fish, + &Nu, + &Tcsh, + &Pwsh, + &Xonsh, + ] + .into_iter() + .filter(move |sh| sh.does_exist(process)) } pub(crate) trait UnixShell { @@ -128,7 +124,7 @@ pub(crate) trait UnixShell { } } - fn cargo_home_str(&self, process: &Process) -> anyhow::Result> { + fn cargo_home_str(&self, process: &Process) -> anyhow::Result { #[cfg(windows)] let home = "%USERPROFILE%"; #[cfg(not(windows))] @@ -364,7 +360,7 @@ impl UnixShell for Nu { )) } - fn cargo_home_str(&self, process: &Process) -> anyhow::Result> { + fn cargo_home_str(&self, process: &Process) -> anyhow::Result { cargo_home_str_with_home("~", process) } } @@ -556,7 +552,7 @@ impl UnixShell for Xonsh { )) } - fn cargo_home_str(&self, process: &Process) -> anyhow::Result> { + fn cargo_home_str(&self, process: &Process) -> anyhow::Result { cargo_home_str_with_home("$HOME", process) } } From deb1edcf476b45f2b3a364d3bbcf7d2b02e4ae62 Mon Sep 17 00:00:00 2001 From: Cloud0310 <60375730+Cloud0310@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:34:52 +0800 Subject: [PATCH 03/19] refactor(install): accept explicit installation paths Pass bin directories to binary, proxy, and updater helpers, and pass the resolved Cargo home to Windows uninstall GC. Give shell script helpers separate env and bin directory inputs while preserving legacy home-variable spelling. --- src/cli/proxy_mode.rs | 2 +- src/cli/rustup_mode.rs | 2 +- src/cli/self_update.rs | 52 +++++++++----------- src/cli/self_update/shell.rs | 87 +++++++++++++++++++++------------- src/cli/self_update/unix.rs | 26 +++++++--- src/cli/self_update/windows.rs | 9 ++-- 6 files changed, 104 insertions(+), 74 deletions(-) diff --git a/src/cli/proxy_mode.rs b/src/cli/proxy_mode.rs index 64204cc285..406cd3fa86 100644 --- a/src/cli/proxy_mode.rs +++ b/src/cli/proxy_mode.rs @@ -14,7 +14,7 @@ pub async fn main( current_dir: PathBuf, process: &Process, ) -> anyhow::Result { - self_update::cleanup_self_updater(process)?; + self_update::cleanup_self_updater(&process.cargo_home()?.join("bin"))?; let _setup = job::setup(); let mut args = process.args_os().skip(1); diff --git a/src/cli/rustup_mode.rs b/src/cli/rustup_mode.rs index 1fec1ce59d..e0a27f6bdf 100644 --- a/src/cli/rustup_mode.rs +++ b/src/cli/rustup_mode.rs @@ -703,7 +703,7 @@ pub async fn main( .bin("rustup") .complete(); - self_update::cleanup_self_updater(process)?; + self_update::cleanup_self_updater(&process.cargo_home()?.join("bin"))?; use clap::error::ErrorKind::*; let matches = match Rustup::try_parse_from(process.args_os()) { diff --git a/src/cli/self_update.rs b/src/cli/self_update.rs index 755e1fac98..38fa209062 100644 --- a/src/cli/self_update.rs +++ b/src/cli/self_update.rs @@ -242,7 +242,8 @@ impl InstallOpts<'_> { quiet: bool, process: &Process, ) -> anyhow::Result<()> { - install_bins(process)?; + let cargo_bin = process.cargo_home()?.join("bin"); + install_bins(&cargo_bin, force_hard_links(process))?; #[cfg(unix)] unix::write_env_files(process)?; @@ -287,7 +288,7 @@ impl InstallOpts<'_> { DistributableToolchain::install(options).await?.status }; - check_proxy_sanity(cfg.process, components, &desc)?; + check_proxy_sanity(&cargo_bin, components, &desc)?; cfg.set_default(Some(&partial_desc.into()))?; writeln!(cfg.process.stdout().lock())?; @@ -771,12 +772,11 @@ fn warn_if_default_linker_missing(process: &Process) { } } -fn install_bins(process: &Process) -> anyhow::Result<()> { - let bin_path = process.cargo_home()?.join("bin"); +fn install_bins(bin_path: &Path, force_hard_links: bool) -> anyhow::Result<()> { let this_exe_path = utils::current_exe()?; let rustup_path = bin_path.join(format!("rustup{EXE_SUFFIX}")); - utils::ensure_dir_exists("bin", &bin_path)?; + utils::ensure_dir_exists("bin", bin_path)?; // NB: Even on Linux we can't just copy the new binary over the (running) // old binary; we must unlink it first. if rustup_path.exists() { @@ -784,22 +784,22 @@ fn install_bins(process: &Process) -> anyhow::Result<()> { } utils::copy_file_symlink_to_source(&this_exe_path, &rustup_path)?; utils::make_executable(&rustup_path)?; - install_proxies(process) + install_proxies_with_opts(bin_path, force_hard_links) } pub(crate) fn install_proxies(process: &Process) -> anyhow::Result<()> { - install_proxies_with_opts( - process, - // HACK: On Windows CI machines, some Docker setups don't like symlinks, so we force hard - // links in this case. - // See: - (cfg!(windows) && process.is_ci()) - || process.var_os("RUSTUP_FORCE_HARDLINK_PROXIES").is_some(), - ) + let bin_path = process.cargo_home()?.join("bin"); + install_proxies_with_opts(&bin_path, force_hard_links(process)) } -fn install_proxies_with_opts(process: &Process, force_hard_links: bool) -> anyhow::Result<()> { - let bin_path = process.cargo_home()?.join("bin"); +fn force_hard_links(process: &Process) -> bool { + // HACK: On Windows CI machines, some Docker setups don't like symlinks, so we force hard + // links in this case. + // See: + (cfg!(windows) && process.is_ci()) || process.var_os("RUSTUP_FORCE_HARDLINK_PROXIES").is_some() +} + +fn install_proxies_with_opts(bin_path: &Path, force_hard_links: bool) -> anyhow::Result<()> { let rustup_path = bin_path.join(format!("rustup{EXE_SUFFIX}")); let rustup = Handle::from_path(&rustup_path)?; @@ -893,7 +893,7 @@ fn install_proxies_with_opts(process: &Process, force_hard_links: bool) -> anyho // This may fail for symlinks in some circumstances. let path = bin_path.join(format!("{tool}{EXE_SUFFIX}", tool = TOOLS[0])); if fs::File::open(path).is_err() { - return install_proxies_with_opts(process, true); + return install_proxies_with_opts(bin_path, true); } } @@ -901,12 +901,10 @@ fn install_proxies_with_opts(process: &Process, force_hard_links: bool) -> anyho } fn check_proxy_sanity( - process: &Process, + bin_path: &Path, components: &[&str], desc: &ToolchainDesc, ) -> anyhow::Result<()> { - let bin_path = process.cargo_home()?.join("bin"); - // 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 // don't run headfirst into the wall is to at least try and run our freshly @@ -991,7 +989,7 @@ pub(crate) fn uninstall( // 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, &cargo_home)?; info!("rustup is uninstalled"); @@ -1372,9 +1370,8 @@ 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}")); +pub(crate) fn cleanup_self_updater(bin_path: &Path) -> anyhow::Result<()> { + let setup = bin_path.join(format!("rustup-init{EXE_SUFFIX}")); if setup.exists() { utils::remove_file("setup", &setup)?; @@ -1393,7 +1390,7 @@ mod tests { dist::{PartialToolchainDesc, Profile}, for_host, process::TestProcess, - test::{Env, test_dir, with_rustup_home}, + test::{test_dir, with_rustup_home}, }; #[test] @@ -1438,10 +1435,7 @@ info: default host tuple is {0} fn install_bins_creates_cargo_home() { let root_dir = test_dir().unwrap(); let cargo_home = root_dir.path().join("cargo"); - let mut vars = HashMap::new(); - vars.env("CARGO_HOME", cargo_home.to_string_lossy().to_string()); - let tp = TestProcess::with_vars(vars); - super::install_bins(&tp.process).unwrap(); + super::install_bins(&cargo_home.join("bin"), false).unwrap(); assert!(cargo_home.exists()); } } diff --git a/src/cli/self_update/shell.rs b/src/cli/self_update/shell.rs index 3e2aaf9421..470612bc4b 100644 --- a/src/cli/self_update/shell.rs +++ b/src/cli/self_update/shell.rs @@ -23,7 +23,7 @@ //! 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::path::PathBuf; +use std::path::{Path, PathBuf}; use anyhow::bail; @@ -37,15 +37,17 @@ pub(crate) struct ShellScript { } // 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 { - format!("{home}/.cargo") +fn path_str_with_home( + home: &str, + path: &Path, + home_dir: Option<&Path>, + default_suffix: &str, +) -> anyhow::Result { + let default_path = home_dir + .unwrap_or_else(|| Path::new(".")) + .join(default_suffix); + Ok(if default_path == path { + format!("{home}/{default_suffix}") } else { match path.to_str() { Some(p) => p.to_owned(), @@ -58,9 +60,13 @@ fn cargo_home_str_with_home(home: &str, process: &Process) -> anyhow::Result String { + let Ok(cargo_home) = process.cargo_home() else { + return String::new(); + }; + let home_dir = process.home_dir(); 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(&cargo_home, home_dir.as_deref()) else { continue; }; if let Some(names) = groups @@ -124,22 +130,34 @@ pub(crate) trait UnixShell { } } - fn cargo_home_str(&self, process: &Process) -> anyhow::Result { + fn home_var(&self) -> &'static str { #[cfg(windows)] let home = "%USERPROFILE%"; #[cfg(not(windows))] let home = "$HOME"; - cargo_home_str_with_home(home, process) + home } - fn source_string(&self, process: &Process) -> anyhow::Result { - Ok(format!(r#". "{}/env""#, self.cargo_home_str(process)?)) + fn env_dir_str(&self, env_dir: &Path, home_dir: Option<&Path>) -> anyhow::Result { + path_str_with_home(self.home_var(), env_dir, home_dir, ".cargo") } - 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); + fn source_string(&self, env_dir: &Path, home_dir: Option<&Path>) -> anyhow::Result { + Ok(format!( + r#". "{}/env""#, + self.env_dir_str(env_dir, home_dir)? + )) + } + + fn write_script( + &self, + script: &ShellScript, + env_dir: &Path, + bin_dir: &Path, + home_dir: Option<&Path>, + ) -> anyhow::Result<()> { + let cargo_bin = path_str_with_home(self.home_var(), bin_dir, home_dir, ".cargo/bin")?; + let env_name = env_dir.join(script.name); let env_file = script.content.replace("{cargo_bin}", &cargo_bin); utils::write_file(script.name, &env_name, &env_file)?; Ok(()) @@ -301,10 +319,10 @@ impl UnixShell for Fish { } } - fn source_string(&self, process: &Process) -> anyhow::Result { + fn source_string(&self, env_dir: &Path, home_dir: Option<&Path>) -> anyhow::Result { Ok(format!( r#"source "{}/env.fish""#, - self.cargo_home_str(process)? + self.env_dir_str(env_dir, home_dir)? )) } } @@ -353,15 +371,15 @@ impl UnixShell for Nu { } } - fn source_string(&self, process: &Process) -> anyhow::Result { + fn source_string(&self, env_dir: &Path, home_dir: Option<&Path>) -> anyhow::Result { Ok(format!( r#"source "{}/env.nu""#, - self.cargo_home_str(process)? + self.env_dir_str(env_dir, home_dir)? )) } - fn cargo_home_str(&self, process: &Process) -> anyhow::Result { - cargo_home_str_with_home("~", process) + fn home_var(&self) -> &'static str { + "~" } } @@ -410,10 +428,10 @@ impl UnixShell for Tcsh { } } - fn source_string(&self, process: &Process) -> anyhow::Result { + fn source_string(&self, env_dir: &Path, home_dir: Option<&Path>) -> anyhow::Result { Ok(format!( r#"source "{}/env.tcsh""#, - self.cargo_home_str(process)? + self.env_dir_str(env_dir, home_dir)? )) } } @@ -493,8 +511,11 @@ 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_dir: &Path, home_dir: Option<&Path>) -> anyhow::Result { + Ok(format!( + r#". "{}/env.ps1""#, + self.env_dir_str(env_dir, home_dir)? + )) } } @@ -545,15 +566,15 @@ impl UnixShell for Xonsh { } } - fn source_string(&self, process: &Process) -> anyhow::Result { + fn source_string(&self, env_dir: &Path, home_dir: Option<&Path>) -> anyhow::Result { Ok(format!( r#"source "{}/env.xsh""#, - self.cargo_home_str(process)? + self.env_dir_str(env_dir, home_dir)? )) } - fn cargo_home_str(&self, process: &Process) -> anyhow::Result { - cargo_home_str_with_home("$HOME", process) + fn home_var(&self) -> &'static str { + "$HOME" } } diff --git a/src/cli/self_update/unix.rs b/src/cli/self_update/unix.rs index 2ac6ddabcc..365ade57c6 100644 --- a/src/cli/self_update/unix.rs +++ b/src/cli/self_update/unix.rs @@ -54,8 +54,11 @@ pub(crate) fn anti_sudo_check( } pub(crate) fn remove_from_path(process: &Process) -> anyhow::Result<()> { + let cargo_home = process.cargo_home()?; + 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 source_bytes = + format!("{}\n", sh.source_string(&cargo_home, home_dir.as_deref())?).into_bytes(); // Check more files for cleanup than normally are updated. for rc in sh.rc_candidates(process).iter().filter(|rc| rc.is_file()) { @@ -78,8 +81,10 @@ pub(crate) fn remove_from_path(process: &Process) -> anyhow::Result<()> { } pub(crate) fn add_to_path(process: &Process) -> anyhow::Result<()> { + let cargo_home = process.cargo_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(&cargo_home, home_dir.as_deref())?; let source_cmd_with_newline = format!("\n{source_cmd}"); for rc in sh.rcs(process) { @@ -107,13 +112,16 @@ pub(crate) fn add_to_path(process: &Process) -> anyhow::Result<()> { } pub(crate) fn write_env_files(process: &Process) -> anyhow::Result<()> { + let cargo_home = process.cargo_home()?; + let bin_dir = cargo_home.join("bin"); + let home_dir = process.home_dir(); let mut written = vec![]; for sh in shell::get_available_shells(process) { let script = sh.env_script(); // Only write each possible script once. if !written.contains(&script) { - sh.write_script(&script, process)?; + sh.write_script(&script, &cargo_home, &bin_dir, home_dir.as_deref())?; written.push(script); } } @@ -140,7 +148,10 @@ pub(crate) fn run_update(setup_path: &Path, _process: &Process) -> anyhow::Resul /// `$CARGO_HOME/bin/rustup` with the running exe, and updates the /// links to it. pub(crate) fn self_replace(process: &Process) -> anyhow::Result { - install_bins(process)?; + install_bins( + &process.cargo_home()?.join("bin"), + super::force_hard_links(process), + )?; Ok(utils::ExitCode(0)) } @@ -179,7 +190,7 @@ fn remove_legacy_paths(process: &Process) -> anyhow::Result<()> { remove_legacy_source_command( format!( "export PATH=\"{}/bin:$PATH\"\n", - Posix.cargo_home_str(process)? + Posix.env_dir_str(&process.cargo_home()?, process.home_dir().as_deref())? ), process, )?; @@ -187,7 +198,10 @@ fn remove_legacy_paths(process: &Process) -> anyhow::Result<()> { // 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)?), + format!( + "source \"{}/env\"\n", + Posix.env_dir_str(&process.cargo_home()?, process.home_dir().as_deref())? + ), process, )?; diff --git a/src/cli/self_update/windows.rs b/src/cli/self_update/windows.rs index 230e267c9a..bdd40e1505 100644 --- a/src/cli/self_update/windows.rs +++ b/src/cli/self_update/windows.rs @@ -672,7 +672,10 @@ pub(crate) fn run_update(setup_path: &Path, process: &Process) -> anyhow::Result pub(crate) fn self_replace(process: &Process) -> anyhow::Result { wait_for_parent()?; - install_bins(process)?; + install_bins( + &process.cargo_home()?.join("bin"), + super::force_hard_links(process), + )?; Ok(utils::ExitCode(0)) } @@ -706,9 +709,7 @@ 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<()> { - // CARGO_HOME, hopefully empty except for bin/rustup.exe - let cargo_home = process.cargo_home()?; +pub(crate) fn spawn_uninstall_gc(no_modify_path: bool, cargo_home: &Path) -> anyhow::Result<()> { // The rustup.exe bin let rustup_path = cargo_home.join(format!("bin/rustup{EXE_SUFFIX}")); From 9c4f6cb4d8db299f21487765b3e0b7db89d5ab28 Mon Sep 17 00:00:00 2001 From: Cloud0310 <60375730+Cloud0310@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:35:49 +0800 Subject: [PATCH 04/19] refactor(shell): reuse resolved paths during setup and cleanup Pass resolved env and user-home paths into source-line rendering. Share exact source-line removal and reuse the current operation paths and legacy rcfile candidates during cleanup. --- src/cli/self_update.rs | 6 +++- src/cli/self_update/shell.rs | 25 ++++++++------- src/cli/self_update/unix.rs | 62 ++++++++++++------------------------ 3 files changed, 40 insertions(+), 53 deletions(-) diff --git a/src/cli/self_update.rs b/src/cli/self_update.rs index 38fa209062..54afc1b676 100644 --- a/src/cli/self_update.rs +++ b/src/cli/self_update.rs @@ -204,7 +204,11 @@ impl InstallOpts<'_> { format!(post_install_msg_win!(), cargo_home = cargo_home) }; #[cfg(not(windows))] - let source_env_lines = shell::build_source_env_lines(process); + let source_env_lines = { + let env_dir = process.cargo_home()?; + let home_dir = process.home_dir(); + shell::build_source_env_lines(process, &env_dir, home_dir.as_deref()) + }; #[cfg(not(windows))] let msg = if no_modify_path { format!( diff --git a/src/cli/self_update/shell.rs b/src/cli/self_update/shell.rs index 470612bc4b..df5cf1b511 100644 --- a/src/cli/self_update/shell.rs +++ b/src/cli/self_update/shell.rs @@ -59,14 +59,14 @@ fn path_str_with_home( /// Builds the shell source lines for the post-install message, showing only /// 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(cargo_home) = process.cargo_home() else { - return String::new(); - }; - let home_dir = process.home_dir(); +pub(crate) fn build_source_env_lines( + process: &Process, + env_dir: &Path, + home_dir: Option<&Path>, +) -> String { let mut groups = Vec::<(_, Vec<_>)>::new(); for shell in get_available_shells(process) { - let Ok(src) = shell.source_string(&cargo_home, home_dir.as_deref()) else { + let Ok(src) = shell.source_string(env_dir, home_dir) else { continue; }; if let Some(names) = groups @@ -578,14 +578,17 @@ impl UnixShell for Xonsh { } } -pub(crate) fn legacy_paths(process: &Process) -> impl Iterator + '_ { +pub(crate) fn legacy_paths<'a>( + process: &Process, + home_dir: Option<&'a Path>, +) -> impl Iterator + 'a { let zprofiles = Zsh::zdotdir(process) .into_iter() - .chain(process.home_dir()) - .map(|d| d.join(".zprofile")); + .map(|dir| dir.join(".zprofile")) + .chain(home_dir.map(|dir| dir.join(".zprofile"))); let profiles = [".bash_profile", ".profile"] - .iter() - .filter_map(|rc| process.home_dir().map(|d| d.join(rc))); + .into_iter() + .filter_map(move |rc| home_dir.map(|dir| dir.join(rc))); profiles.chain(zprofiles) } diff --git a/src/cli/self_update/unix.rs b/src/cli/self_update/unix.rs index 365ade57c6..f9378261a4 100644 --- a/src/cli/self_update/unix.rs +++ b/src/cli/self_update/unix.rs @@ -57,27 +57,12 @@ pub(crate) fn remove_from_path(process: &Process) -> anyhow::Result<()> { let cargo_home = process.cargo_home()?; let home_dir = process.home_dir(); for sh in shell::get_available_shells(process) { - let source_bytes = - format!("{}\n", sh.source_string(&cargo_home, home_dir.as_deref())?).into_bytes(); - + let source_cmd = sh.source_string(&cargo_home, home_dir.as_deref())?; // Check more files for cleanup than normally are updated. - for rc in sh.rc_candidates(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(); - utils::write_file("rcfile", rc, &new_file)?; - } - } + remove_source_command(&source_cmd, &sh.rc_candidates(process))?; } - remove_legacy_paths(process)?; - - Ok(()) + remove_legacy_paths(process, &cargo_home, home_dir.as_deref()) } pub(crate) fn add_to_path(process: &Process) -> anyhow::Result<()> { @@ -106,7 +91,7 @@ pub(crate) fn add_to_path(process: &Process) -> anyhow::Result<()> { } } - remove_legacy_paths(process)?; + remove_legacy_paths(process, &cargo_home, home_dir.as_deref())?; Ok(()) } @@ -156,18 +141,19 @@ pub(crate) fn self_replace(process: &Process) -> anyhow::Result Ok(utils::ExitCode(0)) } -fn remove_legacy_source_command(source_cmd: String, process: &Process) -> anyhow::Result<()> { - let cmd_bytes = source_cmd.into_bytes(); - for rc in shell::legacy_paths(process).filter(|rc| rc.is_file()) { - let file = utils::read_file("rcfile", &rc)?; +/// Removes the first exact line matching `command` followed by a newline from each existing rcfile. +fn remove_source_command(command: &str, rcfiles: &[PathBuf]) -> anyhow::Result<()> { + let command_bytes = format!("{command}\n").into_bytes(); + for rc in rcfiles.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, &cmd_bytes) { + if let Some(idx) = find_exact_line(&file_bytes, &command_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 + cmd_bytes.len()..]); + new_bytes.extend(&file_bytes[idx + command_bytes.len()..]); let new_file = String::from_utf8(new_bytes).unwrap(); - utils::write_file("rcfile", &rc, &new_file)?; + utils::write_file("rcfile", rc, &new_file)?; } } Ok(()) @@ -183,27 +169,21 @@ fn find_exact_line(file: &[u8], line: &[u8]) -> Option { }) } -fn remove_legacy_paths(process: &Process) -> anyhow::Result<()> { +fn remove_legacy_paths( + process: &Process, + cargo_home: &Path, + home_dir: Option<&Path>, +) -> anyhow::Result<()> { + let cargo_home = Posix.env_dir_str(cargo_home, home_dir)?; + let rcfiles = shell::legacy_paths(process, home_dir).collect::>(); // 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.env_dir_str(&process.cargo_home()?, process.home_dir().as_deref())? - ), - process, - )?; + remove_source_command(&format!("export PATH=\"{cargo_home}/bin:$PATH\""), &rcfiles)?; // 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.env_dir_str(&process.cargo_home()?, process.home_dir().as_deref())? - ), - process, - )?; + remove_source_command(&format!("source \"{cargo_home}/env\""), &rcfiles)?; Ok(()) } From 216382a982c32fbe82d12d4dae11e20f86945dd9 Mon Sep 17 00:00:00 2001 From: Cloud0310 <60375730+Cloud0310@users.noreply.github.com> Date: Wed, 23 Sep 2026 01:01:12 +0800 Subject: [PATCH 05/19] test(install): cover post-install messages --- tests/suite/cli_inst_interactive.rs | 107 +++++++++++++++++++++++++--- 1 file changed, 96 insertions(+), 11 deletions(-) diff --git a/tests/suite/cli_inst_interactive.rs b/tests/suite/cli_inst_interactive.rs index 07e84e98da..e315cb1677 100644 --- a/tests/suite/cli_inst_interactive.rs +++ b/tests/suite/cli_inst_interactive.rs @@ -13,12 +13,19 @@ fn run_input(config: &Config, args: &[&str], input: &str) -> Assert { run_input_with_env(config, args, input, &[]) } -fn run_input_with_env(config: &Config, args: &[&str], input: &str, env: &[(&str, &str)]) -> Assert { +fn run_input_with_env( + config: &Config, + args: &[&str], + input: &str, + env: &[(&str, Option<&str>)], +) -> Assert { let mut cmd = config.cmd(args[0], &args[1..]); - config.env(&mut cmd); for (key, value) in env.iter() { - cmd.env(key, value); + match value { + Some(value) => cmd.env(key, value), + None => cmd.env_remove(key), + }; } cmd.stdin(Stdio::piped()); @@ -54,14 +61,27 @@ async fn update() { // test for the install case. #[tokio::test] async fn smoke_case_install_no_modify_path() { - let cx = CliTestContext::new(Scenario::SimpleV2).await; + let mut cx = CliTestContext::new(Scenario::SimpleV2).await; + // Keep displayed paths short and shell detection independent of the host. + cx.config.cargodir = cx.config.homedir.join(".cargo"); // During an interactive session, after "Press the Enter // key..." the UI emits a blank line, then there is a blank // line that comes from the user pressing enter, then log // 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") - .with_stdout(snapbox::str![[r#" + run_input_with_env( + &cx.config, + &["rustup-init", "--no-modify-path"], + "\n\n", + &[ + ("PATH", Some(cx.config.exedir.to_str().unwrap())), + ("SHELL", Some("/bin/sh")), + // HACK: current XONSH detection is done via `process.var("XONSHRC").is_ok()`, + // setting this as none prevents unwanted modification + ("XONSHRC", None), + ], + ) + .with_stdout(snapbox::str![[r#" ... This path needs to be in your PATH environment variable, but will not be added automatically. @@ -88,7 +108,34 @@ Current installation options: Rust is installed now. Great! ... "#]]) - .is_ok(); + .with_stdout(cfg_select! { + windows => snapbox::str![[r#" +... +Rust is installed now. Great! + +To get started you need Cargo's bin directory (%USERPROFILE%/.cargo/bin) in[..] +your PATH +environment variable. This has not been done automatically. + +Press the Enter key to continue. + +"#]], + _ => snapbox::str![[r#" +... +Rust is installed now. Great! + +To get started you need Cargo's bin directory ($HOME/.cargo/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 $HOME/.cargo. + +Consider running the right command for your shell (note the leading DOT): +. "$HOME/.cargo/env" # For sh/ash/dash/pdksh +... +"#]], + }) + .is_ok(); if cfg!(unix) { assert!(!cx.config.homedir.join(".profile").exists()); assert!(cx.config.cargodir.join("env").exists()); @@ -97,11 +144,49 @@ Rust is installed now. Great! #[tokio::test] async fn smoke_case_install_with_path_install() { - let cx = CliTestContext::new(Scenario::SimpleV2).await; + let mut cx = CliTestContext::new(Scenario::SimpleV2).await; + cx.config.cargodir = cx.config.homedir.join(".cargo"); - run_input(&cx.config, &["rustup-init"], "\n\n") - .is_ok() - .without_stdout("This path needs to be in your PATH environment variable"); + run_input_with_env( + &cx.config, + &["rustup-init"], + "\n\n", + &[ + ("PATH", Some(cx.config.exedir.to_str().unwrap())), + ("SHELL", Some("/bin/sh")), + ("XONSHRC", None), + ], + ) + .is_ok() + .without_stdout("This path needs to be in your PATH environment variable") + .with_stdout(cfg_select! { + windows => snapbox::str![[r#" +... +Rust is installed now. Great! + +To get started you may need to restart your current shell. +This would reload your PATH environment variable to include +Cargo's bin directory (%USERPROFILE%/.cargo/bin). + +Press the Enter key to continue. + +"#]], + _ => snapbox::str![[r#" +... +Rust is installed now. Great! + +To get started you may need to restart your current shell. +This would reload your PATH environment variable to include +Cargo's bin directory ($HOME/.cargo/bin). + +To configure your current shell, you need to source the +corresponding env file under $HOME/.cargo. + +Consider running the right command for your shell (note the leading DOT): +. "$HOME/.cargo/env" # For sh/ash/dash/pdksh +... +"#]], + }); } #[tokio::test] From 5faff66405896e8c0e9222108cecc7a428cba0f9 Mon Sep 17 00:00:00 2001 From: Cloud0310 <60375730+Cloud0310@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:36:37 +0800 Subject: [PATCH 06/19] refactor(install): unify message path inputs across platforms Keep canonical_cargo_home and accept resolved paths without renaming it. Pass complete bin and env directory text to the templates, share post-install wording across platforms, and reuse the resolved homes for source commands. Keep display formatting separate from filesystem paths and use native separators in the uninstall message. --- src/cli/self_update.rs | 70 +++++++++++++++----------------------- src/cli/self_update/msg.rs | 48 +++++++------------------- 2 files changed, 39 insertions(+), 79 deletions(-) diff --git a/src/cli/self_update.rs b/src/cli/self_update.rs index 54afc1b676..792faf77a7 100644 --- a/src/cli/self_update.rs +++ b/src/cli/self_update.rs @@ -191,39 +191,29 @@ impl InstallOpts<'_> { return Ok(ExitCode::FAILURE); } - let cargo_home = canonical_cargo_home(process)?; - #[cfg(windows)] - let cargo_home = cargo_home.replace('\\', r"\\"); - #[cfg(windows)] + let cargo_home = process.cargo_home()?; + let home_dir = process.home_dir(); + let env_dir = canonical_cargo_home(&cargo_home, home_dir.as_deref()); + let cargo_bin_dir = format!("{env_dir}{MAIN_SEPARATOR}bin"); let msg = if no_modify_path { format!( - post_install_msg_win_no_modify_path!(), - cargo_home = cargo_home + post_install_msg_no_modify_path!(), + cargo_bin_dir = cargo_bin_dir ) } else { - format!(post_install_msg_win!(), cargo_home = cargo_home) - }; - #[cfg(not(windows))] - let source_env_lines = { - let env_dir = process.cargo_home()?; - let home_dir = process.home_dir(); - shell::build_source_env_lines(process, &env_dir, home_dir.as_deref()) + format!(post_install_msg!(), cargo_bin_dir = cargo_bin_dir) }; + md(&mut term, msg); #[cfg(not(windows))] - let msg = if no_modify_path { - format!( - post_install_msg_unix_no_modify_path!(), - cargo_home = cargo_home, - source_env_lines = source_env_lines, - ) - } else { + md( + &mut term, format!( post_install_msg_unix!(), - cargo_home = cargo_home, - source_env_lines = source_env_lines, - ) - }; - md(&mut term, msg); + env_dir = env_dir, + source_env_lines = + shell::build_source_env_lines(process, &cargo_home, home_dir.as_deref()), + ), + ); #[cfg(unix)] warn_if_default_linker_missing(process); @@ -598,21 +588,16 @@ fn update_root(process: &Process) -> String { /// `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()?; - - let default_cargo_home = process - .home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".cargo"); - Ok(if default_cargo_home == path { +fn canonical_cargo_home(cargo_home: &Path, home_dir: Option<&Path>) -> Cow<'static, str> { + let default_cargo_home = home_dir.unwrap_or_else(|| Path::new(".")).join(".cargo"); + if default_cargo_home == cargo_home { cfg_select! { windows => r"%USERPROFILE%\.cargo".into(), _ => "$HOME/.cargo".into(), } } else { - path.to_string_lossy().into_owned().into() - }) + cargo_home.to_string_lossy().into_owned().into() + } } fn rustc_or_cargo_exists_in_path(process: &Process) -> anyhow::Result<()> { @@ -691,7 +676,7 @@ 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 cargo_bin_dir = cargo_home.join("bin"); let rustup_home = home::rustup_home()?; if !no_modify_path { @@ -707,7 +692,7 @@ fn pre_install_msg(no_modify_path: bool, process: &Process) -> anyhow::Result anyhow::Result { r"# Rust is installed now. Great! 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). - -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): -{source_env_lines}" +Cargo's bin directory (`{cargo_bin_dir}`). +" }; } -#[cfg(windows)] -macro_rules! post_install_msg_win { +macro_rules! post_install_msg_no_modify_path { () => { r"# Rust is installed now. Great! - -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). +To get started you need Cargo's bin directory (`{cargo_bin_dir}`) in your `PATH` +environment variable. This has not been done automatically. " }; } #[cfg(not(windows))] -macro_rules! post_install_msg_unix_no_modify_path { +macro_rules! post_install_msg_unix { () => { - 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}. + r" +To configure your current shell, you need to source the +corresponding `env` file under `{env_dir}`. Consider running the right command for your shell (note the leading DOT): {source_env_lines}" }; } -#[cfg(windows)] -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. -" - }; -} - macro_rules! pre_uninstall_msg { () => { r"# Thanks for hacking in Rust! This will uninstall all Rust toolchains and data, and remove -`{cargo_home}/bin` from your `PATH` environment variable. +`{cargo_bin_dir}` from your `PATH` environment variable. " }; From e62261eb4f19ca2aa2d3ddbb58aec7914ff052b5 Mon Sep 17 00:00:00 2001 From: Cloud0310 <60375730+Cloud0310@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:18:21 +0800 Subject: [PATCH 07/19] refactor(install): replace canonical_cargo_home with HomeDisplay --- src/cli/self_update.rs | 105 +++++++++++++++++++++++++++++++++-------- 1 file changed, 86 insertions(+), 19 deletions(-) diff --git a/src/cli/self_update.rs b/src/cli/self_update.rs index 792faf77a7..0e2b70ab39 100644 --- a/src/cli/self_update.rs +++ b/src/cli/self_update.rs @@ -32,8 +32,9 @@ //! Deleting the running binary during uninstall is tricky //! and racy on Windows. +#[cfg(unix)] +use std::borrow::Cow; use std::{ - borrow::Cow, env::{self, consts::EXE_SUFFIX}, fmt, fs, io::{self, Write}, @@ -193,15 +194,16 @@ impl InstallOpts<'_> { let cargo_home = process.cargo_home()?; let home_dir = process.home_dir(); - let env_dir = canonical_cargo_home(&cargo_home, home_dir.as_deref()); - let cargo_bin_dir = format!("{env_dir}{MAIN_SEPARATOR}bin"); let msg = if no_modify_path { format!( post_install_msg_no_modify_path!(), - cargo_bin_dir = cargo_bin_dir + cargo_bin_dir = HomeDisplay::new(&cargo_home.join("bin"), home_dir.as_deref()), ) } else { - format!(post_install_msg!(), cargo_bin_dir = cargo_bin_dir) + format!( + post_install_msg!(), + cargo_bin_dir = HomeDisplay::new(&cargo_home.join("bin"), home_dir.as_deref()), + ) }; md(&mut term, msg); #[cfg(not(windows))] @@ -209,7 +211,7 @@ impl InstallOpts<'_> { &mut term, format!( post_install_msg_unix!(), - env_dir = env_dir, + env_dir = HomeDisplay::new(&cargo_home, home_dir.as_deref()), source_env_lines = shell::build_source_env_lines(process, &cargo_home, home_dir.as_deref()), ), @@ -586,17 +588,39 @@ 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 -fn canonical_cargo_home(cargo_home: &Path, home_dir: Option<&Path>) -> Cow<'static, str> { - let default_cargo_home = home_dir.unwrap_or_else(|| Path::new(".")).join(".cargo"); - if default_cargo_home == cargo_home { - cfg_select! { - windows => r"%USERPROFILE%\.cargo".into(), - _ => "$HOME/.cargo".into(), +/// Displays an installation path with a platform-specific home abbreviation. +struct HomeDisplay<'a> { + path: &'a Path, + home_prefix: Option<&'a str>, +} + +impl<'a> HomeDisplay<'a> { + fn new(path: &'a Path, home_dir: Option<&Path>) -> Self { + match home_dir.and_then(|home| path.strip_prefix(home).ok()) { + Some(relative) => Self { + path: relative, + home_prefix: Some(cfg_select! { + windows => "%USERPROFILE%", + _ => "$HOME", + }), + }, + None => Self { + path, + home_prefix: None, + }, } - } else { - cargo_home.to_string_lossy().into_owned().into() + } +} + +impl fmt::Display for HomeDisplay<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if let Some(home_prefix) = self.home_prefix { + f.write_str(home_prefix)?; + if !self.path.is_empty() { + write!(f, "{MAIN_SEPARATOR}")?; + } + } + self.path.display().fmt(f) } } @@ -945,8 +969,8 @@ pub(crate) fn uninstall( let msg = if no_modify_path { pre_uninstall_msg_no_modify_path!().to_owned() } else { - let cargo_home = canonical_cargo_home(&cargo_home, process.home_dir().as_deref()); - let cargo_bin_dir = format!("{cargo_home}{MAIN_SEPARATOR}bin"); + let bin_home = cargo_home.join("bin"); + let cargo_bin_dir = HomeDisplay::new(&bin_home, process.home_dir().as_deref()); format!(pre_uninstall_msg!(), cargo_bin_dir = cargo_bin_dir) }; md(&mut process.stdout(), msg); @@ -1370,8 +1394,9 @@ pub(crate) fn cleanup_self_updater(bin_path: &Path) -> anyhow::Result<()> { #[cfg(test)] mod tests { - use std::collections::HashMap; + use std::{collections::HashMap, path::Path}; + use super::HomeDisplay; use crate::{ cli::self_update::InstallOpts, config::Cfg, @@ -1381,6 +1406,48 @@ mod tests { test::{test_dir, with_rustup_home}, }; + #[test] + fn bin_home_display() { + let home = Path::new("/home/user"); + let cargo_bin_dir = home.join(".cargo").join("bin"); + assert_eq!( + HomeDisplay::new(&cargo_bin_dir, Some(home)).to_string(), + cfg_select! { + windows => r"%USERPROFILE%\.cargo\bin", + _ => "$HOME/.cargo/bin", + } + ); + + let local_bin_dir = home.join(".local").join("bin"); + assert_eq!( + HomeDisplay::new(&local_bin_dir, Some(home)).to_string(), + cfg_select! { + windows => r"%USERPROFILE%\.local\bin", + _ => "$HOME/.local/bin", + } + ); + assert_eq!( + HomeDisplay::new(home, Some(home)).to_string(), + cfg_select! { + windows => "%USERPROFILE%", + _ => "$HOME", + } + ); + + for bin_home in ["/opt/rust/bin", "/home/username/bin", ".cargo/bin"] { + let bin_home = Path::new(bin_home); + assert_eq!( + HomeDisplay::new(bin_home, Some(home)).to_string(), + bin_home.display().to_string() + ); + } + + assert_eq!( + HomeDisplay::new(&cargo_bin_dir, None).to_string(), + cargo_bin_dir.display().to_string() + ); + } + #[test] fn default_toolchain_is_stable() { with_rustup_home(|home| { From 7db39feae16f3e8563f597926ec67a85fe764a3f Mon Sep 17 00:00:00 2001 From: Cloud0310 <60375730+Cloud0310@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:44:32 +0800 Subject: [PATCH 08/19] fix(windows): create an independent temporary uninstall GC executable Copy the running executable into a regular temporary file. Keep temporary path ownership until the delete-on-close handle takes over cleanup. --- src/cli/self_update/windows.rs | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/src/cli/self_update/windows.rs b/src/cli/self_update/windows.rs index bdd40e1505..a0e450dca8 100644 --- a/src/cli/self_update/windows.rs +++ b/src/cli/self_update/windows.rs @@ -1,9 +1,9 @@ use std::{ borrow::Cow, - env::{consts::EXE_SUFFIX, split_paths}, + env::split_paths, ffi::{OsStr, OsString}, fmt, - fs::OpenOptions, + fs::{File, OpenOptions}, io::{self, Write}, mem, os::windows::{ @@ -686,7 +686,7 @@ 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 +// - Copy the running rustup.exe to a temporary file in // CARGO_HOME/../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 @@ -710,20 +710,28 @@ 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, cargo_home: &Path) -> anyhow::Result<()> { - // The rustup.exe bin - let rustup_path = cargo_home.join(format!("bin/rustup{EXE_SUFFIX}")); + // Copy the running executable so GC does not depend on the installed copy. + let rustup_path = utils::current_exe()?; // 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 mut source = File::open(&rustup_path) + .with_context(|| format!("could not open rustup '{}'", rustup_path.display()))?; + let mut gc_file = tempfile::Builder::new() + .prefix("rustup-gc-") + .suffix(".exe") + .tempfile_in(work_path) + .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() @@ -733,6 +741,10 @@ pub(crate) fn spawn_uninstall_gc(no_modify_path: bool, cargo_home: &Path) -> any .open(&gc_exe) .context(CliError::WindowsUninstallMadness)?; + // 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()?; + // 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); From 70f2ca6d1b6d9f3d551dbe7bc3567e57fb65fc88 Mon Sep 17 00:00:00 2001 From: Cloud0310 <60375730+Cloud0310@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:44:48 +0800 Subject: [PATCH 09/19] fix(windows): run uninstall GC from the system temporary directory Create GC outside Cargo home so uninstall does not require write access to its parent. Update the cleanup test to inspect the system temporary directory. --- src/cli/self_update.rs | 2 +- src/cli/self_update/windows.rs | 14 +++++--------- tests/suite/cli_self_upd.rs | 22 +++++++++------------- 3 files changed, 15 insertions(+), 23 deletions(-) diff --git a/src/cli/self_update.rs b/src/cli/self_update.rs index 0e2b70ab39..326430b2be 100644 --- a/src/cli/self_update.rs +++ b/src/cli/self_update.rs @@ -1001,7 +1001,7 @@ pub(crate) fn uninstall( // the process exits. // see: windows::{complete_windows_uninstall,spawn_uninstall_gc} #[cfg(windows)] - windows::spawn_uninstall_gc(no_modify_path, &cargo_home)?; + windows::spawn_uninstall_gc(no_modify_path)?; info!("rustup is uninstalled"); diff --git a/src/cli/self_update/windows.rs b/src/cli/self_update/windows.rs index a0e450dca8..61448ee328 100644 --- a/src/cli/self_update/windows.rs +++ b/src/cli/self_update/windows.rs @@ -687,7 +687,7 @@ pub(crate) fn self_replace(process: &Process) -> anyhow::Result // // Here's what we're going to do: // - Copy the running rustup.exe to a temporary file in -// CARGO_HOME/../rustup-gc-$random.exe. +// 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. @@ -709,21 +709,17 @@ 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, cargo_home: &Path) -> anyhow::Result<()> { +pub(crate) fn spawn_uninstall_gc(no_modify_path: bool) -> anyhow::Result<()> { // Copy the running executable so GC does not depend on the installed copy. let rustup_path = utils::current_exe()?; - - // The directory containing CARGO_HOME - let work_path = cargo_home - .parent() - .expect("CARGO_HOME doesn't have a parent?"); - 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_in(work_path) + .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, diff --git a/tests/suite/cli_self_upd.rs b/tests/suite/cli_self_upd.rs index 827fa19ff8..cdd0d05ac7 100644 --- a/tests/suite/cli_self_upd.rs +++ b/tests/suite/cli_self_upd.rs @@ -380,33 +380,29 @@ 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 = || { - let garbage = fs::read_dir(parent) + let garbage = fs::read_dir(gc_dir.path()) .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()) - }) + .map(|entry| entry.unwrap().path()) .collect::>(); if garbage.is_empty() { Ok(()) From 0406583b28a3ea72b8bb510848ab10c9c8ca8dda Mon Sep 17 00:00:00 2001 From: Cloud0310 <60375730+Cloud0310@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:45:37 +0800 Subject: [PATCH 10/19] feat(home): resolve opt-in category homes Resolve category overrides and platform defaults behind RUSTUP_USE_CATEGORY_HOME. Preserve legacy overrides and defaults, and use Process for installer and uninstaller home resolution. Cover Unix and Windows resolution rules. --- Cargo.toml | 2 + src/cli/self_update.rs | 4 +- src/process.rs | 128 +++++++++++++- src/process/home.rs | 325 ++++++++++++++++++++++++++++++++++++ src/process/home/unix.rs | 191 +++++++++++++++++++++ src/process/home/windows.rs | 85 ++++++++++ 6 files changed, 726 insertions(+), 9 deletions(-) create mode 100644 src/process/home.rs create mode 100644 src/process/home/unix.rs create mode 100644 src/process/home/windows.rs diff --git a/Cargo.toml b/Cargo.toml index f3890ab5f3..32f88e2788 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -118,6 +118,7 @@ features = [ "Win32_Foundation", "Win32_Security", "Win32_Storage_FileSystem", + "Win32_System_Com", "Win32_System_Console", "Win32_System_Diagnostics_ToolHelp", "Win32_System_IO", @@ -130,6 +131,7 @@ features = [ "Win32_System_Threading", "Win32_System_WindowsProgramming", "Win32_UI", + "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", ] version = "0.61" diff --git a/src/cli/self_update.rs b/src/cli/self_update.rs index 326430b2be..82e7f084b2 100644 --- a/src/cli/self_update.rs +++ b/src/cli/self_update.rs @@ -701,7 +701,7 @@ 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_bin_dir = cargo_home.join("bin"); - let rustup_home = home::rustup_home()?; + let rustup_home = process.rustup_home()?; if !no_modify_path { // Brittle code warning: some duplication in unix::add_to_path @@ -988,7 +988,7 @@ pub(crate) fn uninstall( info!("removing rustup home"); // Delete RUSTUP_HOME - let rustup_dir = home::rustup_home()?; + let rustup_dir = process.rustup_home()?; if rustup_dir.exists() { utils::remove_dir("rustup_home", &rustup_dir)?; } diff --git a/src/process.rs b/src/process.rs index 177b8a790b..47f8cd04c8 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,57 @@ 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. + #[allow(dead_code, reason = "split-home interface is not consumed yet")] + 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. + #[allow(dead_code, reason = "split-home interface is not consumed yet")] + 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")) + } + } + + /// Category mode is enabled when `RUSTUP_USE_CATEGORY_HOME` is non-empty + /// and not "0"; values such as "false" also enable it. + 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 +347,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 +358,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 +483,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 +504,73 @@ 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")); + + 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")); + 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")); + } + 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..89c6be6d3d --- /dev/null +++ b/src/process/home.rs @@ -0,0 +1,325 @@ +//! 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("CARGO_HOME", "cargo"); + + let process = test_env(cwd, vars.clone()); + assert_eq!(bin_home(&process)?, Path::new("bin")); + + 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..fccaee01b9 --- /dev/null +++ b/src/process/home/unix.rs @@ -0,0 +1,191 @@ +//! 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 non_absolute_xdg_values_use_defaults() -> Result<()> { + for xdg in [XdgStatus::Missing, XdgStatus::Empty, XdgStatus::Relative] { + 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()), + "{xdg:?}, {category:?}", + ); + } + } + Ok(()) + } + + #[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" + ); + } + } + + 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, Debug)] + 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") + } + } +} From 59364482e2aa6930b4c670e8d56a60fbfa95d8d4 Mon Sep 17 00:00:00 2001 From: Cloud0310 <60375730+Cloud0310@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:45:53 +0800 Subject: [PATCH 11/19] feat(shell): use resolved paths while recognizing legacy source commands Generate shell source commands with resolved paths. Recognize historical source commands during setup and cleanup, with compatibility coverage. --- src/cli/self_update.rs | 3 +- src/cli/self_update/shell.rs | 218 +++++++++++++++++++++------------ src/cli/self_update/unix.rs | 32 +++-- src/cli/self_update/windows.rs | 14 +-- src/process.rs | 2 +- tests/suite/cli_paths.rs | 74 +++++++---- 6 files changed, 224 insertions(+), 119 deletions(-) diff --git a/src/cli/self_update.rs b/src/cli/self_update.rs index 82e7f084b2..658deea781 100644 --- a/src/cli/self_update.rs +++ b/src/cli/self_update.rs @@ -212,8 +212,7 @@ impl InstallOpts<'_> { format!( post_install_msg_unix!(), env_dir = HomeDisplay::new(&cargo_home, home_dir.as_deref()), - source_env_lines = - shell::build_source_env_lines(process, &cargo_home, home_dir.as_deref()), + source_env_lines = shell::build_source_env_lines(process, &cargo_home), ), ); diff --git a/src/cli/self_update/shell.rs b/src/cli/self_update/shell.rs index df5cf1b511..0a4d8e0560 100644 --- a/src/cli/self_update/shell.rs +++ b/src/cli/self_update/shell.rs @@ -25,7 +25,7 @@ use std::path::{Path, PathBuf}; -use anyhow::bail; +use anyhow::{Context, bail}; use super::utils; use crate::process::Process; @@ -36,37 +36,26 @@ pub(crate) struct ShellScript { name: &'static str, } -// TODO: Update into a bytestring. -fn path_str_with_home( - home: &str, - path: &Path, +// 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_suffix: &str, -) -> anyhow::Result { - let default_path = home_dir - .unwrap_or_else(|| Path::new(".")) - .join(default_suffix); - Ok(if default_path == path { - format!("{home}/{default_suffix}") + 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(), - None => bail!("Non-Unicode path!"), - } - }) + env_home.to_str().context("Non-Unicode path!") + } } /// Builds the shell source lines for the post-install message, showing only /// 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, - env_dir: &Path, - home_dir: Option<&Path>, -) -> String { +pub(crate) fn build_source_env_lines(process: &Process, env_home: &Path) -> String { let mut groups = Vec::<(_, Vec<_>)>::new(); for shell in get_available_shells(process) { - let Ok(src) = shell.source_string(env_dir, home_dir) else { + let Ok(src) = shell.source_string(env_home) else { continue; }; if let Some(names) = groups @@ -130,37 +119,33 @@ pub(crate) trait UnixShell { } } - fn home_var(&self) -> &'static str { - #[cfg(windows)] - let home = "%USERPROFILE%"; - #[cfg(not(windows))] - let home = "$HOME"; - home + 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 env_dir_str(&self, env_dir: &Path, home_dir: Option<&Path>) -> anyhow::Result { - path_str_with_home(self.home_var(), env_dir, home_dir, ".cargo") - } - - fn source_string(&self, env_dir: &Path, home_dir: Option<&Path>) -> anyhow::Result { - Ok(format!( - r#". "{}/env""#, - self.env_dir_str(env_dir, home_dir)? - )) + // 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, - env_dir: &Path, - bin_dir: &Path, - home_dir: Option<&Path>, + env_home: &Path, + bin_home: &Path, ) -> anyhow::Result<()> { - let cargo_bin = path_str_with_home(self.home_var(), bin_dir, home_dir, ".cargo/bin")?; - let env_name = env_dir.join(script.name); - let env_file = script.content.replace("{cargo_bin}", &cargo_bin); - utils::write_file(script.name, &env_name, &env_file)?; - Ok(()) + let cargo_bin = bin_home.to_str().context("Non-Unicode path!")?; + utils::write_file( + script.name, + &env_home.join(script.name), + &script.content.replace("{cargo_bin}", cargo_bin), + ) } } @@ -319,11 +304,18 @@ impl UnixShell for Fish { } } - fn source_string(&self, env_dir: &Path, home_dir: Option<&Path>) -> anyhow::Result { - Ok(format!( - r#"source "{}/env.fish""#, - self.env_dir_str(env_dir, home_dir)? - )) + 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""#)) } } @@ -371,15 +363,18 @@ impl UnixShell for Nu { } } - fn source_string(&self, env_dir: &Path, home_dir: Option<&Path>) -> anyhow::Result { - Ok(format!( - r#"source "{}/env.nu""#, - self.env_dir_str(env_dir, home_dir)? - )) + 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 home_var(&self) -> &'static str { - "~" + 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""#)) } } @@ -428,11 +423,18 @@ impl UnixShell for Tcsh { } } - fn source_string(&self, env_dir: &Path, home_dir: Option<&Path>) -> anyhow::Result { - Ok(format!( - r#"source "{}/env.tcsh""#, - self.env_dir_str(env_dir, home_dir)? - )) + 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""#)) } } @@ -511,11 +513,18 @@ impl UnixShell for Pwsh { } } - fn source_string(&self, env_dir: &Path, home_dir: Option<&Path>) -> anyhow::Result { - Ok(format!( - r#". "{}/env.ps1""#, - self.env_dir_str(env_dir, home_dir)? - )) + 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""#)) } } @@ -566,15 +575,18 @@ impl UnixShell for Xonsh { } } - fn source_string(&self, env_dir: &Path, home_dir: Option<&Path>) -> anyhow::Result { - Ok(format!( - r#"source "{}/env.xsh""#, - self.env_dir_str(env_dir, home_dir)? - )) + 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 home_var(&self) -> &'static str { - "$HOME" + 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""#)) } } @@ -592,3 +604,59 @@ pub(crate) fn legacy_paths<'a>( 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 f9378261a4..d651b46585 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 @@ -57,9 +54,14 @@ pub(crate) fn remove_from_path(process: &Process) -> anyhow::Result<()> { let cargo_home = process.cargo_home()?; let home_dir = process.home_dir(); for sh in shell::get_available_shells(process) { - let source_cmd = sh.source_string(&cargo_home, home_dir.as_deref())?; + let commands = [ + sh.source_string(&cargo_home)?, + sh.legacy_source_string(&cargo_home, home_dir.as_deref())?, + ]; // Check more files for cleanup than normally are updated. - remove_source_command(&source_cmd, &sh.rc_candidates(process))?; + for source_cmd in commands { + remove_source_command(&source_cmd, &sh.rc_candidates(process))?; + } } remove_legacy_paths(process, &cargo_home, home_dir.as_deref()) @@ -69,12 +71,19 @@ pub(crate) fn add_to_path(process: &Process) -> anyhow::Result<()> { let cargo_home = process.cargo_home()?; let home_dir = process.home_dir(); for sh in shell::get_available_shells(process) { - let source_cmd = sh.source_string(&cargo_home, home_dir.as_deref())?; + let source_cmd = sh.source_string(&cargo_home)?; + let legacy_cmd = sh.legacy_source_string(&cargo_home, home_dir.as_deref())?; let source_cmd_with_newline = format!("\n{source_cmd}"); for rc in sh.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, }; @@ -98,15 +107,14 @@ pub(crate) fn add_to_path(process: &Process) -> anyhow::Result<()> { pub(crate) fn write_env_files(process: &Process) -> anyhow::Result<()> { let cargo_home = process.cargo_home()?; - let bin_dir = cargo_home.join("bin"); - let home_dir = process.home_dir(); + let bin_home = cargo_home.join("bin"); let mut written = vec![]; for sh in shell::get_available_shells(process) { let script = sh.env_script(); // Only write each possible script once. if !written.contains(&script) { - sh.write_script(&script, &cargo_home, &bin_dir, home_dir.as_deref())?; + sh.write_script(&script, &cargo_home, &bin_home)?; written.push(script); } } @@ -174,7 +182,7 @@ fn remove_legacy_paths( cargo_home: &Path, home_dir: Option<&Path>, ) -> anyhow::Result<()> { - let cargo_home = Posix.env_dir_str(cargo_home, home_dir)?; + let cargo_home = shell::legacy_env_home(cargo_home, home_dir, "$HOME/.cargo")?; let rcfiles = shell::legacy_paths(process, home_dir).collect::>(); // 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 diff --git a/src/cli/self_update/windows.rs b/src/cli/self_update/windows.rs index 61448ee328..d29f0481fe 100644 --- a/src/cli/self_update/windows.rs +++ b/src/cli/self_update/windows.rs @@ -466,7 +466,8 @@ pub(crate) fn wait_for_parent() -> anyhow::Result<()> { } pub(crate) fn add_to_path(process: &Process) -> anyhow::Result<()> { - let new_path = _with_path_cargo_home_bin(_add_to_path, process)?; + let cargo_bin = process.cargo_home()?.join("bin"); + let new_path = _with_path(_add_to_path, &cargo_bin, process)?; _apply_new_path(new_path, process) } @@ -560,18 +561,17 @@ 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 remove_from_path(process: &Process) -> anyhow::Result<()> { - let new_path = _with_path_cargo_home_bin(_remove_from_path, process)?; + let cargo_bin = process.cargo_home()?.join("bin"); + let new_path = _with_path(_remove_from_path, &cargo_bin, process)?; _apply_new_path(new_path, process) } @@ -1036,7 +1036,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/process.rs b/src/process.rs index 47f8cd04c8..e67f3e6ddd 100644 --- a/src/process.rs +++ b/src/process.rs @@ -116,7 +116,7 @@ impl Process { /// Category mode is enabled when `RUSTUP_USE_CATEGORY_HOME` is non-empty /// and not "0"; values such as "false" also enable it. - fn use_category_home(&self) -> bool { + pub(crate) fn use_category_home(&self) -> bool { self.var_os("RUSTUP_USE_CATEGORY_HOME") .is_some_and(|value| value != "0") } diff --git a/tests/suite/cli_paths.rs b/tests/suite/cli_paths.rs index cc723459ef..9150220cb3 100644 --- a/tests/suite/cli_paths.rs +++ b/tests/suite/cli_paths.rs @@ -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,10 +59,14 @@ 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); } @@ -269,6 +272,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 +405,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 +429,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 +492,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] From 7e2c0fbf8d5505baad7e3910493b25ac1aedfffb Mon Sep 17 00:00:00 2001 From: Cloud0310 <60375730+Cloud0310@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:46:10 +0800 Subject: [PATCH 12/19] feat(cache): store cached files in the cache home Use the resolved cache home for downloads, temporary files and update hashes. Forward the resolved home to child processes and cover cache placement. --- src/config.rs | 22 ++++++++++++-------- src/dist/download.rs | 2 +- src/process.rs | 1 - src/test/clitools.rs | 1 + src/test/mock_bin_src.rs | 1 + src/toolchain.rs | 1 + tests/suite/cli_v2.rs | 44 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 61 insertions(+), 11 deletions(-) diff --git a/src/config.rs b/src/config.rs index f8765924d3..c9cdaccff6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -322,7 +322,7 @@ pub(crate) struct Cfg<'a> { state_file: StateFile, fallback_settings: Option, pub toolchains_dir: PathBuf, - update_hash_dir: PathBuf, + pub rustup_cache_dir: PathBuf, pub download_dir: PathBuf, pub toolchain_override: Option>, env_override: Option>, @@ -350,6 +350,7 @@ impl<'a> Cfg<'a> { ) -> anyhow::Result { // Set up the rustup home directory let rustup_dir = process.rustup_home()?; + let rustup_cache_dir = process.home_dirs()?.cache; utils::ensure_dir_exists("home", &rustup_dir)?; @@ -381,8 +382,7 @@ impl<'a> Cfg<'a> { 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 download_dir = rustup_cache_dir.join("downloads"); // Environment override let env_override = match &process.var_opt("RUSTUP_TOOLCHAIN")? { @@ -400,7 +400,7 @@ impl<'a> Cfg<'a> { state_file, fallback_settings, toolchains_dir, - update_hash_dir, + rustup_cache_dir, download_dir, toolchain_override: None, env_override, @@ -529,11 +529,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 +563,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())?; @@ -1184,7 +1188,7 @@ impl Debug for Cfg<'_> { state_file, fallback_settings, toolchains_dir, - update_hash_dir, + rustup_cache_dir, download_dir, toolchain_override, env_override, @@ -1203,7 +1207,7 @@ impl Debug for Cfg<'_> { .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("download_dir", download_dir) .field("toolchain_override", toolchain_override) .field("env_override", env_override) 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 e67f3e6ddd..1ddd8ab20c 100644 --- a/src/process.rs +++ b/src/process.rs @@ -85,7 +85,6 @@ impl Process { /// 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. - #[allow(dead_code, reason = "split-home interface is not consumed yet")] pub(crate) fn home_dirs(&self) -> io::Result { if self.use_category_home() { home::category_homes(self) diff --git a/src/test/clitools.rs b/src/test/clitools.rs index d2dad716b7..a5a6cfe5ea 100644 --- a/src/test/clitools.rs +++ b/src/test/clitools.rs @@ -791,6 +791,7 @@ 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_TOOLCHAIN"); env::remove_var("SHELL"); env::remove_var("ZDOTDIR"); 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..2add67b465 100644 --- a/src/toolchain.rs +++ b/src/toolchain.rs @@ -180,6 +180,7 @@ impl<'a> Toolchain<'a> { cmd.env("RUSTUP_TOOLCHAIN", format!("{}", self.name)); cmd.env("RUSTUP_HOME", &self.cfg.rustup_dir); + cmd.env("RUSTUP_CACHE_HOME", &self.cfg.rustup_cache_dir); } /// Apply the appropriate LD path for a command being run from a toolchain. diff --git a/tests/suite/cli_v2.rs b/tests/suite/cli_v2.rs index 6d8ae541a7..f89c80929a 100644 --- a/tests/suite/cli_v2.rs +++ b/tests/suite/cli_v2.rs @@ -1960,6 +1960,50 @@ warn: removing the last target; no build targets will be available .is_ok(); } +#[tokio::test] +async fn install_uses_cache_home() { + 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}"))); + + 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] // Issue #304 async fn remove_target_missing_update_hash() { From c7df46470deb29e3bcc7afa2e3150828ee26dc03 Mon Sep 17 00:00:00 2001 From: Cloud0310 <60375730+Cloud0310@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:47:18 +0800 Subject: [PATCH 13/19] feat(toolchain): store toolchains and fallback data in the data home Use the resolved data home for installed toolchains and fallback commands. Forward the data home to child processes and cover both storage paths. --- src/config.rs | 10 ++++++++-- src/test/clitools.rs | 1 + src/toolchain.rs | 1 + src/toolchain/distributable.rs | 2 +- tests/suite/cli_rustup.rs | 26 ++++++++++++++++++++------ tests/suite/cli_v2.rs | 22 ++++++++++++++++++++++ 6 files changed, 53 insertions(+), 9 deletions(-) diff --git a/src/config.rs b/src/config.rs index c9cdaccff6..1d7c5c6846 100644 --- a/src/config.rs +++ b/src/config.rs @@ -323,6 +323,7 @@ pub(crate) struct Cfg<'a> { fallback_settings: Option, pub toolchains_dir: PathBuf, pub rustup_cache_dir: PathBuf, + pub rustup_data_dir: PathBuf, pub download_dir: PathBuf, pub toolchain_override: Option>, env_override: Option>, @@ -350,7 +351,9 @@ impl<'a> Cfg<'a> { ) -> anyhow::Result { // Set up the rustup home directory let rustup_dir = process.rustup_home()?; - let rustup_cache_dir = process.home_dirs()?.cache; + let home_dirs = process.home_dirs()?; + let rustup_cache_dir = home_dirs.cache; + let rustup_data_dir = home_dirs.data; utils::ensure_dir_exists("home", &rustup_dir)?; @@ -381,7 +384,7 @@ impl<'a> Cfg<'a> { #[cfg(windows)] let fallback_settings = None; - let toolchains_dir = rustup_dir.join("toolchains"); + let toolchains_dir = rustup_data_dir.join("toolchains"); let download_dir = rustup_cache_dir.join("downloads"); // Environment override @@ -401,6 +404,7 @@ impl<'a> Cfg<'a> { fallback_settings, toolchains_dir, rustup_cache_dir, + rustup_data_dir, download_dir, toolchain_override: None, env_override, @@ -1189,6 +1193,7 @@ impl Debug for Cfg<'_> { fallback_settings, toolchains_dir, rustup_cache_dir, + rustup_data_dir, download_dir, toolchain_override, env_override, @@ -1208,6 +1213,7 @@ impl Debug for Cfg<'_> { .field("fallback_settings", fallback_settings) .field("toolchains_dir", toolchains_dir) .field("rustup_cache_dir", rustup_cache_dir) + .field("rustup_data_dir", rustup_data_dir) .field("download_dir", download_dir) .field("toolchain_override", toolchain_override) .field("env_override", env_override) diff --git a/src/test/clitools.rs b/src/test/clitools.rs index a5a6cfe5ea..69e80d070c 100644 --- a/src/test/clitools.rs +++ b/src/test/clitools.rs @@ -792,6 +792,7 @@ async fn setup_test_state(test_dist_dir: TempDir) -> (TempDir, Config) { env::remove_var("RUSTUP_AUTO_INSTALL"); env::remove_var("RUSTUP_UPDATE_ROOT"); env::remove_var("RUSTUP_CACHE_HOME"); + env::remove_var("RUSTUP_DATA_HOME"); env::remove_var("RUSTUP_TOOLCHAIN"); env::remove_var("SHELL"); env::remove_var("ZDOTDIR"); diff --git a/src/toolchain.rs b/src/toolchain.rs index 2add67b465..ae5411d845 100644 --- a/src/toolchain.rs +++ b/src/toolchain.rs @@ -181,6 +181,7 @@ impl<'a> Toolchain<'a> { cmd.env("RUSTUP_TOOLCHAIN", format!("{}", self.name)); cmd.env("RUSTUP_HOME", &self.cfg.rustup_dir); cmd.env("RUSTUP_CACHE_HOME", &self.cfg.rustup_cache_dir); + cmd.env("RUSTUP_DATA_HOME", &self.cfg.rustup_data_dir); } /// Apply the appropriate LD path for a command being run from a toolchain. diff --git a/src/toolchain/distributable.rs b/src/toolchain/distributable.rs index 1fdec3cc5d..8527af5fc4 100644 --- a/src/toolchain/distributable.rs +++ b/src/toolchain/distributable.rs @@ -227,7 +227,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_rustup.rs b/tests/suite/cli_rustup.rs index 0dc27db6cd..d6ad3b42d3 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 diff --git a/tests/suite/cli_v2.rs b/tests/suite/cli_v2.rs index f89c80929a..28d84de7dd 100644 --- a/tests/suite/cli_v2.rs +++ b/tests/suite/cli_v2.rs @@ -2004,6 +2004,28 @@ async fn install_uses_cache_home() { assert!(!cache_home.join("update-hashes").join(&toolchain).is_file()); } +#[tokio::test] +async fn install_uses_data_home() { + 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}"))); +} + #[tokio::test] // Issue #304 async fn remove_target_missing_update_hash() { From 7323738b08f5da2cdbe7f2113c478d4c2b712662 Mon Sep 17 00:00:00 2001 From: Cloud0310 <60375730+Cloud0310@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:48:07 +0800 Subject: [PATCH 14/19] feat(state): store runtime state in the state home Create the state directory and read and write state.toml there. Forward the resolved home to child processes and verify persistent release-hint state. --- src/config.rs | 10 +++++++++- src/test/clitools.rs | 1 + src/toolchain.rs | 1 + tests/suite/cli_rustup.rs | 18 +++++++++++++----- 4 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/config.rs b/src/config.rs index 1d7c5c6846..6878c6c1b0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -324,6 +324,7 @@ pub(crate) struct Cfg<'a> { pub toolchains_dir: PathBuf, pub rustup_cache_dir: PathBuf, pub rustup_data_dir: PathBuf, + pub rustup_state_dir: PathBuf, pub download_dir: PathBuf, pub toolchain_override: Option>, env_override: Option>, @@ -354,8 +355,12 @@ impl<'a> Cfg<'a> { let home_dirs = process.home_dirs()?; let rustup_cache_dir = home_dirs.cache; let rustup_data_dir = home_dirs.data; + let rustup_state_dir = home_dirs.state; utils::ensure_dir_exists("home", &rustup_dir)?; + if process.use_category_home() { + utils::ensure_dir_exists("state home", &rustup_state_dir)?; + } let settings_file = SettingsFile::new(rustup_dir.join("settings.toml")); settings_file.with(|s| { @@ -369,7 +374,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)] @@ -405,6 +410,7 @@ impl<'a> Cfg<'a> { toolchains_dir, rustup_cache_dir, rustup_data_dir, + rustup_state_dir, download_dir, toolchain_override: None, env_override, @@ -1194,6 +1200,7 @@ impl Debug for Cfg<'_> { toolchains_dir, rustup_cache_dir, rustup_data_dir, + rustup_state_dir, download_dir, toolchain_override, env_override, @@ -1214,6 +1221,7 @@ impl Debug for Cfg<'_> { .field("toolchains_dir", toolchains_dir) .field("rustup_cache_dir", rustup_cache_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) diff --git a/src/test/clitools.rs b/src/test/clitools.rs index 69e80d070c..6a4b560792 100644 --- a/src/test/clitools.rs +++ b/src/test/clitools.rs @@ -793,6 +793,7 @@ async fn setup_test_state(test_dist_dir: TempDir) -> (TempDir, Config) { env::remove_var("RUSTUP_UPDATE_ROOT"); env::remove_var("RUSTUP_CACHE_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"); diff --git a/src/toolchain.rs b/src/toolchain.rs index ae5411d845..121475ead0 100644 --- a/src/toolchain.rs +++ b/src/toolchain.rs @@ -182,6 +182,7 @@ impl<'a> Toolchain<'a> { cmd.env("RUSTUP_HOME", &self.cfg.rustup_dir); cmd.env("RUSTUP_CACHE_HOME", &self.cfg.rustup_cache_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. diff --git a/tests/suite/cli_rustup.rs b/tests/suite/cli_rustup.rs index d6ad3b42d3..5a490aa9a2 100644 --- a/tests/suite/cli_rustup.rs +++ b/tests/suite/cli_rustup.rs @@ -1077,18 +1077,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 @@ -1096,10 +1102,12 @@ 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")); } #[tokio::test] From b32ee6aba186ec3f0b1c2d7333410f6485453b84 Mon Sep 17 00:00:00 2001 From: Cloud0310 <60375730+Cloud0310@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:48:37 +0800 Subject: [PATCH 15/19] feat(config): store settings in the config home Read and write settings in the resolved config home, including installer checks. Initialize config and state without creating an unused legacy home, and cover split-home installation and settings persistence. --- src/cli/self_update.rs | 13 +----------- src/config.rs | 11 ++++++++-- src/test/clitools.rs | 1 + src/toolchain.rs | 1 + tests/suite/cli_exact.rs | 22 ++++++++++++++++++++ tests/suite/cli_inst_interactive.rs | 31 ++++++++++++++++++++++++++++- 6 files changed, 64 insertions(+), 15 deletions(-) diff --git a/src/cli/self_update.rs b/src/cli/self_update.rs index 658deea781..8f2fd4057c 100644 --- a/src/cli/self_update.rs +++ b/src/cli/self_update.rs @@ -250,16 +250,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); @@ -675,8 +665,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(()); } diff --git a/src/config.rs b/src/config.rs index 6878c6c1b0..0f41ee3da3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -323,6 +323,7 @@ pub(crate) struct Cfg<'a> { fallback_settings: Option, pub toolchains_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, @@ -354,15 +355,18 @@ impl<'a> Cfg<'a> { let rustup_dir = process.rustup_home()?; 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; - utils::ensure_dir_exists("home", &rustup_dir)?; 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() { @@ -409,6 +413,7 @@ impl<'a> Cfg<'a> { fallback_settings, toolchains_dir, rustup_cache_dir, + rustup_config_dir, rustup_data_dir, rustup_state_dir, download_dir, @@ -1199,6 +1204,7 @@ impl Debug for Cfg<'_> { fallback_settings, toolchains_dir, rustup_cache_dir, + rustup_config_dir, rustup_data_dir, rustup_state_dir, download_dir, @@ -1220,6 +1226,7 @@ impl Debug for Cfg<'_> { .field("fallback_settings", fallback_settings) .field("toolchains_dir", toolchains_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) diff --git a/src/test/clitools.rs b/src/test/clitools.rs index 6a4b560792..a69f6f19c7 100644 --- a/src/test/clitools.rs +++ b/src/test/clitools.rs @@ -792,6 +792,7 @@ async fn setup_test_state(test_dist_dir: TempDir) -> (TempDir, Config) { 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"); diff --git a/src/toolchain.rs b/src/toolchain.rs index 121475ead0..927f62152a 100644 --- a/src/toolchain.rs +++ b/src/toolchain.rs @@ -181,6 +181,7 @@ impl<'a> Toolchain<'a> { cmd.env("RUSTUP_TOOLCHAIN", format!("{}", self.name)); cmd.env("RUSTUP_HOME", &self.cfg.rustup_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); } diff --git a/tests/suite/cli_exact.rs b/tests/suite/cli_exact.rs index 20ebaf71bd..958cc524db 100644 --- a/tests/suite/cli_exact.rs +++ b/tests/suite/cli_exact.rs @@ -657,6 +657,28 @@ help: run 'rustup default stable' to download the latest stable release of Rust "#]]); } +#[tokio::test] +async fn default_uses_config_home() { + 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")); +} + #[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 e315cb1677..972f18566f 100644 --- a/tests/suite/cli_inst_interactive.rs +++ b/tests/suite/cli_inst_interactive.rs @@ -343,6 +343,28 @@ 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 with_no_toolchain_doesnt_hang() { let cx = CliTestContext::new(Scenario::SimpleV2).await; @@ -710,8 +732,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!( @@ -723,6 +749,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 @@ -731,6 +758,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 From 738ad9e333d791bf2d02f795a12dd107ea35e19f Mon Sep 17 00:00:00 2001 From: Cloud0310 <60375730+Cloud0310@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:50:00 +0800 Subject: [PATCH 16/19] fix(config): avoid resolving legacy homes in category mode Remove the unused legacy home from Cfg. Preserve explicit legacy overrides without injecting resolved defaults into category-mode child environments, and cover operation without a resolvable legacy home. Verify resolved category homes are forwarded for unset, empty and explicit overrides. --- src/cli/rustup_mode.rs | 8 ++- src/config.rs | 23 +++++-- src/toolchain.rs | 13 +++- tests/suite/cli_rustup.rs | 141 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 175 insertions(+), 10 deletions(-) diff --git a/src/cli/rustup_mode.rs b/src/cli/rustup_mode.rs index e0a27f6bdf..8f1a29d120 100644 --- a/src/cli/rustup_mode.rs +++ b/src/cli/rustup_mode.rs @@ -1277,7 +1277,7 @@ async fn show(cfg: &Cfg<'_>, verbose: bool) -> anyhow::Result { writeln!( t, "{HEADER}rustup home: {HEADER:#}{}", - cfg.rustup_dir.display() + cfg.rustup_data_dir.display() )?; writeln!(t)?; } @@ -1426,7 +1426,11 @@ async fn show_active_toolchain(cfg: &Cfg<'_>, verbose: bool) -> anyhow::Result) -> anyhow::Result { - writeln!(cfg.process.stdout().lock(), "{}", cfg.rustup_dir.display())?; + writeln!( + cfg.process.stdout().lock(), + "{}", + cfg.rustup_data_dir.display() + )?; Ok(ExitCode::SUCCESS) } diff --git a/src/config.rs b/src/config.rs index 0f41ee3da3..05b06016f6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -317,7 +317,6 @@ 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, @@ -352,7 +351,6 @@ impl<'a> Cfg<'a> { process: &'a Process, ) -> anyhow::Result { // Set up the rustup home directory - let rustup_dir = process.rustup_home()?; let home_dirs = process.home_dirs()?; let rustup_cache_dir = home_dirs.cache; let rustup_config_dir = home_dirs.config; @@ -407,7 +405,6 @@ impl<'a> Cfg<'a> { let cfg = Self { profile_override: None, - rustup_dir, settings_file, state_file, fallback_settings, @@ -1198,7 +1195,6 @@ 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, @@ -1220,7 +1216,6 @@ 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) @@ -1334,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/toolchain.rs b/src/toolchain.rs index 927f62152a..e409a83e88 100644 --- a/src/toolchain.rs +++ b/src/toolchain.rs @@ -171,15 +171,22 @@ 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); diff --git a/tests/suite/cli_rustup.rs b/tests/suite/cli_rustup.rs index 5a490aa9a2..b9eb3ddf7e 100644 --- a/tests/suite/cli_rustup.rs +++ b/tests/suite/cli_rustup.rs @@ -905,6 +905,147 @@ error: infinite recursion detected .is_err(); } +#[tokio::test] +async fn category_child_receives_resolved_homes() { + let cx = CliTestContext::new(Scenario::SimpleV2).await; + cx.config + .expect(["rustup", "default", "stable"]) + .await + .is_ok(); + + let legacy_home = &cx.config.rustupdir.rustupdir; + let category_homes = [ + ("RUSTUP_CACHE_HOME", cx.config.current_dir().join("cache")), + ("RUSTUP_CONFIG_HOME", cx.config.current_dir().join("config")), + ("RUSTUP_DATA_HOME", legacy_home.clone()), + ("RUSTUP_STATE_HOME", cx.config.current_dir().join("state")), + ]; + + for (key, explicit_home) in &category_homes { + for value in [None, Some(Path::new("")), Some(explicit_home.as_path())] { + let mut cmd = cx.config.cmd("rustc", ["+stable", "--echo-env", key]); + cmd.env("RUSTUP_USE_CATEGORY_HOME", "1") + .envs(category_homes.iter().map(|(key, path)| (*key, path))); + match value { + Some(path) => { + cmd.env(key, path); + } + None => { + cmd.env_remove(key); + } + } + + // Unset and empty overrides must be resolved from RUSTUP_HOME and + // explicitly forwarded; inheriting the parent environment cannot pass. + let expected = value + .filter(|path| !path.as_os_str().is_empty()) + .unwrap_or(legacy_home); + let output = cmd.output().unwrap(); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(output.status.success(), "{key}={value:?}: {stderr}"); + assert_eq!(stderr.trim(), expected.to_string_lossy(), "{key}={value:?}"); + } + } +} + +#[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_home() { let cx = CliTestContext::new(Scenario::None).await; From 49f4c7fa5683336fdfb5a80f0720773ff68e0cb1 Mon Sep 17 00:00:00 2001 From: Cloud0310 <60375730+Cloud0310@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:50:16 +0800 Subject: [PATCH 17/19] feat(installer): use category homes for binaries and shell scripts Use resolved bin and env homes for installation, proxies, self-update, PATH and Windows registration. Display split homes with BinHomeDisplay and cover installation and shell setup with category overrides. --- src/cli/proxy_mode.rs | 2 +- src/cli/rustup_mode.rs | 2 +- src/cli/self_update.rs | 108 +++++++++++++++++----------- src/cli/self_update/env.fish | 4 +- src/cli/self_update/env.nu | 2 +- src/cli/self_update/env.ps1 | 4 +- src/cli/self_update/env.sh | 4 +- src/cli/self_update/env.tcsh | 6 +- src/cli/self_update/env.xsh | 2 +- src/cli/self_update/msg.rs | 24 +++---- src/cli/self_update/shell.rs | 5 +- src/cli/self_update/unix.rs | 20 +++--- src/cli/self_update/windows.rs | 50 +++++++++++-- src/process.rs | 17 ++++- src/process/home.rs | 5 ++ src/test/clitools.rs | 7 ++ src/toolchain.rs | 6 +- tests/suite/cli_inst_interactive.rs | 107 +++++++++++++++++++++++++-- tests/suite/cli_paths.rs | 49 +++++++++++-- tests/suite/cli_self_upd.rs | 2 +- 20 files changed, 322 insertions(+), 104 deletions(-) diff --git a/src/cli/proxy_mode.rs b/src/cli/proxy_mode.rs index 406cd3fa86..bba3bd206f 100644 --- a/src/cli/proxy_mode.rs +++ b/src/cli/proxy_mode.rs @@ -14,7 +14,7 @@ pub async fn main( current_dir: PathBuf, process: &Process, ) -> anyhow::Result { - self_update::cleanup_self_updater(&process.cargo_home()?.join("bin"))?; + self_update::cleanup_self_updater(&process.rustup_bin_home()?)?; let _setup = job::setup(); let mut args = process.args_os().skip(1); diff --git a/src/cli/rustup_mode.rs b/src/cli/rustup_mode.rs index 8f1a29d120..39ada397d2 100644 --- a/src/cli/rustup_mode.rs +++ b/src/cli/rustup_mode.rs @@ -703,7 +703,7 @@ pub async fn main( .bin("rustup") .complete(); - self_update::cleanup_self_updater(&process.cargo_home()?.join("bin"))?; + self_update::cleanup_self_updater(&process.rustup_bin_home()?)?; use clap::error::ErrorKind::*; let matches = match Rustup::try_parse_from(process.args_os()) { diff --git a/src/cli/self_update.rs b/src/cli/self_update.rs index 8f2fd4057c..daab4b2aba 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 @@ -192,29 +192,32 @@ impl InstallOpts<'_> { return Ok(ExitCode::FAILURE); } - let cargo_home = process.cargo_home()?; + let bin_home = process.rustup_bin_home()?; let home_dir = process.home_dir(); let msg = if no_modify_path { format!( post_install_msg_no_modify_path!(), - cargo_bin_dir = HomeDisplay::new(&cargo_home.join("bin"), home_dir.as_deref()), + rustup_bin_home = HomeDisplay::new(&bin_home, home_dir.as_deref()), ) } else { format!( post_install_msg!(), - cargo_bin_dir = HomeDisplay::new(&cargo_home.join("bin"), home_dir.as_deref()), + rustup_bin_home = HomeDisplay::new(&bin_home, home_dir.as_deref()), ) }; md(&mut term, msg); #[cfg(not(windows))] - md( - &mut term, - format!( - post_install_msg_unix!(), - env_dir = HomeDisplay::new(&cargo_home, home_dir.as_deref()), - source_env_lines = shell::build_source_env_lines(process, &cargo_home), - ), - ); + { + let env_home = process.rustup_env_home()?; + md( + &mut term, + format!( + post_install_msg_unix!(), + env_dir = HomeDisplay::new(&env_home, home_dir.as_deref()), + source_env_lines = shell::build_source_env_lines(process, &env_home), + ), + ); + } #[cfg(unix)] warn_if_default_linker_missing(process); @@ -237,8 +240,8 @@ impl InstallOpts<'_> { quiet: bool, process: &Process, ) -> anyhow::Result<()> { - let cargo_bin = process.cargo_home()?.join("bin"); - install_bins(&cargo_bin, force_hard_links(process))?; + let bin_home = process.rustup_bin_home()?; + install_bins(&bin_home, force_hard_links(process))?; #[cfg(unix)] unix::write_env_files(process)?; @@ -273,7 +276,7 @@ impl InstallOpts<'_> { DistributableToolchain::install(options).await?.status }; - check_proxy_sanity(&cargo_bin, components, &desc)?; + check_proxy_sanity(&bin_home, components, &desc)?; cfg.set_default(Some(&partial_desc.into()))?; writeln!(cfg.process.stdout().lock())?; @@ -622,8 +625,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}")); @@ -687,9 +692,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_bin_dir = cargo_home.join("bin"); - let rustup_home = process.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::add_to_path @@ -703,26 +736,23 @@ fn pre_install_msg(no_modify_path: bool, process: &Process) -> anyhow::Result anyhow::Result<()> { } pub(crate) fn install_proxies(process: &Process) -> anyhow::Result<()> { - let bin_path = process.cargo_home()?.join("bin"); + let bin_path = process.rustup_bin_home()?; install_proxies_with_opts(&bin_path, force_hard_links(process)) } @@ -1122,8 +1152,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 +1243,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() { 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 c2d1d37c8b..43fac4a4a9 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_bin_dir} +This can be modified with CARGO_HOME, or overridden in category +home mode with RUSTUP_BIN_HOME. ", $platform_msg, @@ -76,7 +68,7 @@ macro_rules! post_install_msg { 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_bin_dir}`). +Rustup's bin directory (`{rustup_bin_home}`). " }; } @@ -85,7 +77,7 @@ macro_rules! post_install_msg_no_modify_path { () => { r"# Rust is installed now. Great! -To get started you need Cargo's bin directory (`{cargo_bin_dir}`) in your `PATH` +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 0a4d8e0560..44f5d1f363 100644 --- a/src/cli/self_update/shell.rs +++ b/src/cli/self_update/shell.rs @@ -140,11 +140,12 @@ pub(crate) trait UnixShell { env_home: &Path, bin_home: &Path, ) -> anyhow::Result<()> { - let cargo_bin = bin_home.to_str().context("Non-Unicode path!")?; + 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("{cargo_bin}", cargo_bin), + &script.content.replace("{rustup_bin}", rustup_bin), ) } } diff --git a/src/cli/self_update/unix.rs b/src/cli/self_update/unix.rs index d651b46585..f4ecec0a40 100644 --- a/src/cli/self_update/unix.rs +++ b/src/cli/self_update/unix.rs @@ -51,7 +51,7 @@ pub(crate) fn anti_sudo_check( } pub(crate) fn remove_from_path(process: &Process) -> anyhow::Result<()> { - let cargo_home = process.cargo_home()?; + let cargo_home = process.rustup_env_home()?; let home_dir = process.home_dir(); for sh in shell::get_available_shells(process) { let commands = [ @@ -68,11 +68,12 @@ pub(crate) fn remove_from_path(process: &Process) -> anyhow::Result<()> { } pub(crate) fn add_to_path(process: &Process) -> anyhow::Result<()> { + let env_home = process.rustup_env_home()?; let cargo_home = process.cargo_home()?; let home_dir = process.home_dir(); for sh in shell::get_available_shells(process) { - let source_cmd = sh.source_string(&cargo_home)?; - let legacy_cmd = sh.legacy_source_string(&cargo_home, home_dir.as_deref())?; + 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.rcs(process) { @@ -106,15 +107,15 @@ pub(crate) fn add_to_path(process: &Process) -> anyhow::Result<()> { } pub(crate) fn write_env_files(process: &Process) -> anyhow::Result<()> { - let cargo_home = process.cargo_home()?; - let bin_home = cargo_home.join("bin"); + let env_home = process.rustup_env_home()?; + let bin_home = process.rustup_bin_home()?; let mut written = vec![]; for sh in shell::get_available_shells(process) { let script = sh.env_script(); // Only write each possible script once. if !written.contains(&script) { - sh.write_script(&script, &cargo_home, &bin_home)?; + sh.write_script(&script, &env_home, &bin_home)?; written.push(script); } } @@ -137,12 +138,11 @@ 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.cargo_home()?.join("bin"), + &process.rustup_bin_home()?, super::force_hard_links(process), )?; diff --git a/src/cli/self_update/windows.rs b/src/cli/self_update/windows.rs index d29f0481fe..f1ea4d9c3a 100644 --- a/src/cli/self_update/windows.rs +++ b/src/cli/self_update/windows.rs @@ -466,8 +466,8 @@ pub(crate) fn wait_for_parent() -> anyhow::Result<()> { } pub(crate) fn add_to_path(process: &Process) -> anyhow::Result<()> { - let cargo_bin = process.cargo_home()?.join("bin"); - let new_path = _with_path(_add_to_path, &cargo_bin, 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) } @@ -632,8 +632,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"); @@ -673,7 +672,7 @@ pub(crate) fn run_update(setup_path: &Path, process: &Process) -> anyhow::Result pub(crate) fn self_replace(process: &Process) -> anyhow::Result { wait_for_parent()?; install_bins( - &process.cargo_home()?.join("bin"), + &process.rustup_bin_home()?, super::force_hard_links(process), )?; @@ -842,6 +841,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(); diff --git a/src/process.rs b/src/process.rs index 1ddd8ab20c..b639fcb65a 100644 --- a/src/process.rs +++ b/src/process.rs @@ -104,7 +104,6 @@ impl Process { /// 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. - #[allow(dead_code, reason = "split-home interface is not consumed yet")] pub(crate) fn rustup_bin_home(&self) -> io::Result { if self.use_category_home() { home::bin_home(self) @@ -113,6 +112,19 @@ impl Process { } } + /// 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 { @@ -522,6 +534,7 @@ mod tests { } ); 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")); @@ -536,6 +549,7 @@ mod tests { } ); assert_eq!(process.rustup_bin_home()?, Path::new("/cargo/bin")); + assert_eq!(process.rustup_env_home()?, Path::new("/cargo")); Ok(()) } @@ -561,6 +575,7 @@ mod tests { } ); assert_eq!(process.rustup_bin_home()?, Path::new("bin")); + assert_eq!(process.rustup_env_home()?, Path::new("config")); } Ok(()) } diff --git a/src/process/home.rs b/src/process/home.rs index 89c6be6d3d..c7386b6307 100644 --- a/src/process/home.rs +++ b/src/process/home.rs @@ -192,10 +192,15 @@ mod tests { 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!( diff --git a/src/test/clitools.rs b/src/test/clitools.rs index a69f6f19c7..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 @@ -804,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/toolchain.rs b/src/toolchain.rs index e409a83e88..7be80bb064 100644 --- a/src/toolchain.rs +++ b/src/toolchain.rs @@ -238,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/tests/suite/cli_inst_interactive.rs b/tests/suite/cli_inst_interactive.rs index 972f18566f..993a35b0f8 100644 --- a/tests/suite/cli_inst_interactive.rs +++ b/tests/suite/cli_inst_interactive.rs @@ -63,6 +63,8 @@ async fn update() { async fn smoke_case_install_no_modify_path() { let mut cx = CliTestContext::new(Scenario::SimpleV2).await; // Keep displayed paths short and shell detection independent of the host. + let home = tempfile::tempdir().unwrap(); + cx.config.homedir = home.path().to_owned(); cx.config.cargodir = cx.config.homedir.join(".cargo"); // During an interactive session, after "Press the Enter // key..." the UI emits a blank line, then there is a blank @@ -81,8 +83,17 @@ async fn smoke_case_install_no_modify_path() { ("XONSHRC", None), ], ) + .extend_redactions([("[RUSTUP_DIR]", &cx.config.rustupdir.to_string())]) + .extend_redactions([("[HOME]", cx.config.homedir.clone())]) .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. @@ -113,7 +124,7 @@ Rust is installed now. Great! ... Rust is installed now. Great! -To get started you need Cargo's bin directory (%USERPROFILE%/.cargo/bin) in[..] +To get started you need Rustup's bin directory (%USERPROFILE%/.cargo/bin) in[..] your PATH environment variable. This has not been done automatically. @@ -124,14 +135,14 @@ Press the Enter key to continue. ... Rust is installed now. Great! -To get started you need Cargo's bin directory ($HOME/.cargo/bin) in your PATH +To get started you need Rustup's bin directory ($HOME/.cargo/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 $HOME/.cargo. Consider running the right command for your shell (note the leading DOT): -. "$HOME/.cargo/env" # For sh/ash/dash/pdksh +. "[HOME]/.cargo/env" # For sh/ash/dash/pdksh ... "#]], }) @@ -145,6 +156,8 @@ Consider running the right command for your shell (note the leading DOT): #[tokio::test] async fn smoke_case_install_with_path_install() { let mut cx = CliTestContext::new(Scenario::SimpleV2).await; + let home = tempfile::tempdir().unwrap(); + cx.config.homedir = home.path().to_owned(); cx.config.cargodir = cx.config.homedir.join(".cargo"); run_input_with_env( @@ -157,6 +170,7 @@ async fn smoke_case_install_with_path_install() { ("XONSHRC", None), ], ) + .extend_redactions([("[HOME]", cx.config.homedir.clone())]) .is_ok() .without_stdout("This path needs to be in your PATH environment variable") .with_stdout(cfg_select! { @@ -166,7 +180,7 @@ Rust is installed now. Great! To get started you may need to restart your current shell. This would reload your PATH environment variable to include -Cargo's bin directory (%USERPROFILE%/.cargo/bin). +Rustup's bin directory (%USERPROFILE%/.cargo/bin). Press the Enter key to continue. @@ -177,13 +191,13 @@ Rust is installed now. Great! To get started you may need to restart your current shell. This would reload your PATH environment variable to include -Cargo's bin directory ($HOME/.cargo/bin). +Rustup's bin directory ($HOME/.cargo/bin). To configure your current shell, you need to source the corresponding env file under $HOME/.cargo. Consider running the right command for your shell (note the leading DOT): -. "$HOME/.cargo/env" # For sh/ash/dash/pdksh +. "[HOME]/.cargo/env" # For sh/ash/dash/pdksh ... "#]], }); @@ -365,6 +379,87 @@ async fn install_with_split_homes_does_not_create_legacy_home() { assert!(state_home.is_dir()); } +fn category_home_install_output(config: &Config, homes: [&str; 4]) -> Assert { + let [config_home, state_home, data_home, cache_home] = homes; + run_input_with_env( + config, + &["rustup-init", "--no-modify-path"], + "3\n", + &[ + ("RUSTUP_USE_CATEGORY_HOME", Some("1")), + ("RUSTUP_HOME", Some(&config.rustupdir.to_string())), + ("RUSTUP_CONFIG_HOME", Some(config_home)), + ("RUSTUP_STATE_HOME", Some(state_home)), + ("RUSTUP_DATA_HOME", Some(data_home)), + ("RUSTUP_CACHE_HOME", Some(cache_home)), + ], + ) +} + +#[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()), + ]; + + category_home_install_output( + &cx.config, + [ + config_home.to_str().unwrap(), + state_home.to_str().unwrap(), + data_home.to_str().unwrap(), + cache_home.to_str().unwrap(), + ], + ) + .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(); + category_home_install_output(&cx.config, [&home; 4]) + .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; diff --git a/tests/suite/cli_paths.rs b/tests/suite/cli_paths.rs index 9150220cb3..9edc644482 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}, @@ -72,6 +72,46 @@ export PATH="$HOME/apple/bin" } } + #[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; @@ -152,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; } diff --git a/tests/suite/cli_self_upd.rs b/tests/suite/cli_self_upd.rs index cdd0d05ac7..fca91ed258 100644 --- a/tests/suite/cli_self_upd.rs +++ b/tests/suite/cli_self_upd.rs @@ -513,7 +513,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' "#]]); } From 5109cb03e1957a168f0f8371ba119330a278ddbd Mon Sep 17 00:00:00 2001 From: Cloud0310 <60375730+Cloud0310@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:50:31 +0800 Subject: [PATCH 18/19] feat(cli): display resolved category homes Show resolved category directories in rustup show and rustup show home. Preserve legacy output and update help snapshots and CLI coverage. --- src/cli/rustup_mode.rs | 46 ++++++++---- tests/suite/cli_rustup.rs | 72 +++++++++++++++++++ .../rustup_show_cmd_help_flag.stdout.term.svg | 2 +- ...how_cmd_home_cmd_help_flag.stdout.term.svg | 2 +- 4 files changed, 108 insertions(+), 14 deletions(-) diff --git a/src/cli/rustup_mode.rs b/src/cli/rustup_mode.rs index 39ada397d2..9c1b3377b1 100644 --- a/src/cli/rustup_mode.rs +++ b/src/cli/rustup_mode.rs @@ -388,7 +388,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 @@ -1271,14 +1271,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_data_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)?; } @@ -1426,11 +1439,20 @@ async fn show_active_toolchain(cfg: &Cfg<'_>, verbose: bool) -> anyhow::Result) -> anyhow::Result { - writeln!( - cfg.process.stdout().lock(), - "{}", - cfg.rustup_data_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/tests/suite/cli_rustup.rs b/tests/suite/cli_rustup.rs index b9eb3ddf7e..d2b6b18425 100644 --- a/tests/suite/cli_rustup.rs +++ b/tests/suite/cli_rustup.rs @@ -1046,6 +1046,78 @@ async fn child_cargo_home_preserves_legacy_compatibility() { } } +#[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; 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 From 721b6b14eaadb161a6104fa4b396093ff8b9d185 Mon Sep 17 00:00:00 2001 From: Cloud0310 <60375730+Cloud0310@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:50:39 +0800 Subject: [PATCH 19/19] feat(uninstall): clean legacy and category installations Remove legacy and category homes and Rustup-owned binaries from both bin directories. Preserve unrelated programs, honor --no-modify-path, and cover shared directories and cleanup after removing the working directory. --- src/cli/self_update.rs | 173 ++++++++++++------- src/cli/self_update/unix.rs | 9 +- src/cli/self_update/windows.rs | 8 +- tests/suite/cli_self_upd.rs | 307 +++++++++++++++++++++++++++++++++ 4 files changed, 430 insertions(+), 67 deletions(-) diff --git a/src/cli/self_update.rs b/src/cli/self_update.rs index daab4b2aba..561610c064 100644 --- a/src/cli/self_update.rs +++ b/src/cli/self_update.rs @@ -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. @@ -958,7 +958,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. @@ -977,8 +977,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()); } @@ -998,6 +1004,15 @@ pub(crate) fn uninstall( } } + #[cfg(unix)] + if process.use_category_home() && !no_modify_path { + let config_home = &cfg.rustup_config_dir; + remove_from_path(process, config_home)?; + if config_home != &cargo_home { + remove_from_path(process, &cargo_home)?; + } + } + info!("removing toolchains"); for toolchain in cfg.list_toolchains(true)? { Toolchain::ensure_removed(cfg, toolchain.into())?; @@ -1005,15 +1020,24 @@ pub(crate) fn uninstall( info!("removing rustup home"); - // Delete RUSTUP_HOME - let rustup_dir = process.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, &cargo_home)?; + 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. @@ -1027,80 +1051,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, cargo_home: &Path, + category_bin: &Path, ) -> anyhow::Result<()> { - let cargo_bin = cargo_home.join("bin"); + 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.to_owned(), - source: e, - })?; - for dirent in diriter { - let dirent = dirent.map_err(|e| CliError::ReadDirError { - p: cargo_home.to_owned(), - 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)] + remove_from_path(process, bin)?; + #[cfg(unix)] + if !process.use_category_home() && bin == legacy_bin { + 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"); - 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) { + 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}`"); } @@ -1115,6 +1136,42 @@ fn clean_cargo_home( 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, diff --git a/src/cli/self_update/unix.rs b/src/cli/self_update/unix.rs index f4ecec0a40..33f43f655f 100644 --- a/src/cli/self_update/unix.rs +++ b/src/cli/self_update/unix.rs @@ -50,13 +50,12 @@ pub(crate) fn anti_sudo_check( Ok(utils::ExitCode(0)) } -pub(crate) fn remove_from_path(process: &Process) -> anyhow::Result<()> { - let cargo_home = process.rustup_env_home()?; +pub(crate) fn 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 commands = [ - sh.source_string(&cargo_home)?, - sh.legacy_source_string(&cargo_home, home_dir.as_deref())?, + 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 source_cmd in commands { @@ -64,7 +63,7 @@ pub(crate) fn remove_from_path(process: &Process) -> anyhow::Result<()> { } } - remove_legacy_paths(process, &cargo_home, home_dir.as_deref()) + remove_legacy_paths(process, env_home, home_dir.as_deref()) } pub(crate) fn add_to_path(process: &Process) -> anyhow::Result<()> { diff --git a/src/cli/self_update/windows.rs b/src/cli/self_update/windows.rs index f1ea4d9c3a..7fdb5e4b44 100644 --- a/src/cli/self_update/windows.rs +++ b/src/cli/self_update/windows.rs @@ -388,7 +388,8 @@ pub fn complete_windows_uninstall(process: &Process) -> anyhow::Result anyhow::Result<()> { - let cargo_bin = process.cargo_home()?.join("bin"); - let new_path = _with_path(_remove_from_path, &cargo_bin, process)?; +pub(crate) fn 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) } diff --git a/tests/suite/cli_self_upd.rs b/tests/suite/cli_self_upd.rs index fca91ed258..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;