From c0337367d4e38d577e858d34e215cdf3a3ad5bf4 Mon Sep 17 00:00:00 2001 From: Cloud0310 <60375730+Cloud0310@users.noreply.github.com> Date: Sat, 26 Sep 2026 03:18:42 +0800 Subject: [PATCH 01/14] fix(windows): make uninstall GC independent of installation directories --- src/cli/self_update.rs | 2 +- src/cli/self_update/windows.rs | 45 ++++++++++++++++++++-------------- tests/suite/cli_self_upd.rs | 25 +++++++++---------- 3 files changed, 39 insertions(+), 33 deletions(-) diff --git a/src/cli/self_update.rs b/src/cli/self_update.rs index 9e153decd0..d38ed26b05 100644 --- a/src/cli/self_update.rs +++ b/src/cli/self_update.rs @@ -996,7 +996,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 0305fadb61..d1d73a5d5d 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::{ @@ -722,8 +722,8 @@ pub(crate) fn self_replace(process: &Process) -> anyhow::Result // while they are open, like when they are running. // // Here's what we're going to do: -// - Copy rustup.exe to a temporary file in -// CARGO_HOME/../rustup-gc-$random.exe. +// - Copy the running rustup.exe to a temporary file in +// the system temporary directory as rustup-gc-$random.exe. // - Open the gc exe with the FILE_FLAG_DELETE_ON_CLOSE and // FILE_SHARE_DELETE flags. This is going to be the last // file to remove, and the OS is going to do it for us. @@ -745,21 +745,24 @@ 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}")); - - // 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)?; +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()?; + let mut source = File::open(&rustup_path) + .with_context(|| format!("could not open rustup '{}'", rustup_path.display()))?; + // Use the system temporary directory so GC creation does not require + // write access to CARGO_HOME's parent. + let mut gc_file = tempfile::Builder::new() + .prefix("rustup-gc-") + .suffix(".exe") + .tempfile() + .context("error creating temporary GC executable")?; + // `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() @@ -769,6 +772,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()?; + let mut command = Command::new(gc_exe); command .stdin(gc_handle) diff --git a/tests/suite/cli_self_upd.rs b/tests/suite/cli_self_upd.rs index 82ea7a71c9..1c79ec9214 100644 --- a/tests/suite/cli_self_upd.rs +++ b/tests/suite/cli_self_upd.rs @@ -387,33 +387,32 @@ 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(); + // std::env::temp_dir() uses TMP, or SystemTemp for SYSTEM processes: + // https://doc.rust-lang.org/std/env/fn.temp_dir.html + // https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-gettemppath2w cx.config - .expect(["rustup", "self", "uninstall", "-y"]) + .expect_with_env( + ["rustup", "self", "uninstall", "-y"], + [("SystemTemp", gc_path), ("TMP", 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 0d331c1a7c8c62b4a8b2c271ec2e4fafa523e5e9 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 02/14] feat(home): resolve opt-in category homes --- Cargo.toml | 2 + src/cli/self_update.rs | 4 +- src/process.rs | 127 +++++++++++++++++- src/process/home.rs | 258 ++++++++++++++++++++++++++++++++++++ src/process/home/unix.rs | 175 ++++++++++++++++++++++++ src/process/home/windows.rs | 70 ++++++++++ 6 files changed, 627 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 23807c7ad4..a88aff4cb1 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 d38ed26b05..fa1a094444 100644 --- a/src/cli/self_update.rs +++ b/src/cli/self_update.rs @@ -707,7 +707,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 @@ -983,7 +983,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 53508789a5..082dfd79cf 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,54 @@ 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 resolves each directory independently through [`home`]. + /// Legacy mode uses the resolved Rustup home for all four categories. + #[allow(dead_code, reason = "split-home interface is not consumed yet")] + pub(crate) fn home_dirs(&self) -> io::Result { + if self.use_category_home() { + return HomeDirs::from_env(self); + } + + 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 the binary directory resolver in [`home`]. + /// Legacy mode appends `bin` to the resolved Cargo home. + #[allow(dead_code, reason = "split-home interface is not consumed yet")] + pub(crate) fn bin_home(&self) -> io::Result { + if self.use_category_home() { + return home::bin_home(self); + } + + 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. + pub(crate) fn use_category_home(&self) -> bool { + self.var_os("RUSTUP_USE_CATEGORY_HOME") + .is_some_and(|value| value != "0") } pub fn io_thread_count(&self) -> anyhow::Result { @@ -305,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()), } @@ -316,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(), } @@ -441,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}; @@ -462,4 +504,75 @@ mod tests { // non-tty + `auto` does not enable the colors. assert_color_choice("aUTo", false, ColorChoice::Never); } + + #[test] + fn legacy_mode_uses_legacy_homes() -> io::Result<()> { + let home = crate::test::test_dir()?; + let home_dir = home.path(); + let mut vars = HashMap::new(); + vars.env("RUSTUP_USE_CATEGORY_HOME", "0"); + vars.env("HOME", home_dir); + 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"); + + let tp = TestProcess::with_vars(vars.clone()); + let homes = tp.process.home_dirs()?; + for home in [&homes.cache, &homes.config, &homes.data, &homes.state] { + assert_eq!(home, &home_dir.join(".rustup")); + } + assert_eq!(tp.process.bin_home()?, home_dir.join(".cargo/bin")); + + let rustup_home = home_dir.join("legacy"); + let cargo_home = home_dir.join("cargo"); + vars.env("RUSTUP_HOME", &rustup_home); + vars.env("CARGO_HOME", &cargo_home); + let tp = TestProcess::with_vars(vars); + let homes = tp.process.home_dirs()?; + for home in [&homes.cache, &homes.config, &homes.data, &homes.state] { + assert_eq!(home, &rustup_home); + } + assert_eq!(tp.process.bin_home()?, cargo_home.join("bin")); + Ok(()) + } + + #[test] + fn category_mode_uses_category_homes() -> io::Result<()> { + let home = crate::test::test_dir()?; + let home_dir = home.path(); + let mut vars = HashMap::new(); + vars.env("RUSTUP_USE_CATEGORY_HOME", "1"); + vars.env("HOME", home_dir); + + let tp = TestProcess::with_vars(vars.clone()); + // TestProcess cannot mock the Windows Known Folder API, so default + // category paths are only tested on Unix. + #[cfg(unix)] + { + let homes = tp.process.home_dirs()?; + assert_eq!(homes.cache, home_dir.join(".cache/rustup")); + assert_eq!(homes.config, home_dir.join(".config/rustup")); + assert_eq!(homes.data, home_dir.join(".local/share/rustup")); + assert_eq!(homes.state, home_dir.join(".local/state/rustup")); + } + assert_eq!(tp.process.bin_home()?, home_dir.join(".local/bin")); + + vars.env("RUSTUP_HOME", home_dir.join("legacy")); + vars.env("CARGO_HOME", home_dir.join("cargo")); + 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"); + let tp = TestProcess::with_vars(vars); + let homes = tp.process.home_dirs()?; + assert_eq!(homes.cache, Path::new("cache")); + assert_eq!(homes.config, Path::new("config")); + assert_eq!(homes.data, Path::new("data")); + assert_eq!(homes.state, Path::new("state")); + assert_eq!(tp.process.bin_home()?, Path::new("bin")); + Ok(()) + } } diff --git a/src/process/home.rs b/src/process/home.rs new file mode 100644 index 0000000000..ddaec8f1e6 --- /dev/null +++ b/src/process/home.rs @@ -0,0 +1,258 @@ +/*! +Resolve Rustup's category and binary directories in category mode. + +`Process` selects the mode and handles legacy paths separately. This module +only computes paths; it does not check the mode switch, create directories, +or choose paths based on whether they exist. + +# Category directories + +Cache, config, data, and state each resolve independently, in this order: + +1. `RUSTUP__HOME`. +2. `RUSTUP_HOME`. +3. The platform's category directory with `rustup` appended. + +# Binary directory + +The bin directory resolves in this order: + +1. `RUSTUP_BIN_HOME`. +2. `CARGO_HOME` with `bin` appended. +3. The user home directory with `.local/bin` appended. + +Platform category directories: + - follow XDG rules on Unix + - use Known Folders on Windows +Bin Home uses `~/.local/bin` on both platforms + +TODO: The Windows bin default is not yet finalized. +*/ + +use std::{io, path::PathBuf}; + +use home::env::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, +} + +impl HomeDirs { + /// Resolves each category's directory in category mode. + pub(super) fn from_env(env: &impl Env) -> io::Result { + Ok(Self { + 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. +pub(super) fn category_home(category: HomeCategory, env: &impl Env) -> io::Result { + if let Some(explicit_override) = path_from_env(category.override_env_var(), env) { + return Ok(explicit_override); + } + + if let Some(rustup_home) = path_from_env("RUSTUP_HOME", env) { + if rustup_home.is_absolute() { + return Ok(rustup_home); + } + return Ok(env.current_dir()?.join(rustup_home)); + } + + category_dir(category, env).map(|platform_dir| platform_dir.join("rustup")) +} + +#[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", + } + } +} + +/// Resolves the binary directory in category mode. +pub(super) fn bin_home(env: &impl Env) -> io::Result { + if let Some(explicit_override) = path_from_env("RUSTUP_BIN_HOME", env) { + return Ok(explicit_override); + } + + if let Some(cargo_home) = path_from_env("CARGO_HOME", env) { + let cargo_home = if cargo_home.is_absolute() { + cargo_home + } else { + env.current_dir()?.join(cargo_home) + }; + return Ok(cargo_home.join("bin")); + } + + env.home_dir() + .map(|home_dir| home_dir.join(".local/bin")) + .ok_or_else(|| io::Error::other("could not find home dir")) +} + +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 { + use std::{collections::HashMap, env}; + + use super::*; + use crate::{process::TestProcess, test::Env as _}; + + #[test] + fn category_home_resolves_explicit_override() -> io::Result<()> { + let cwd = env::current_dir()?; + let overrides = [ + (HomeCategory::Cache, "RUSTUP_CACHE_HOME", cwd.join("cache")), + (HomeCategory::Config, "RUSTUP_CONFIG_HOME", "config".into()), + (HomeCategory::Data, "RUSTUP_DATA_HOME", cwd.join("data")), + (HomeCategory::State, "RUSTUP_STATE_HOME", "state".into()), + ]; + let mut vars = HashMap::new(); + vars.env("RUSTUP_HOME", cwd.join("rustup")); + vars.env("HOME", cwd.join("home")); + for (_, key, explicit_override) in &overrides { + vars.env(key, explicit_override); + } + + let tp = TestProcess::new(&cwd, &[] as &[&str], vars, ""); + for (category, _, expected) in overrides { + assert_eq!(category_home(category, &tp.process)?, expected); + } + Ok(()) + } + + #[test] + fn category_home_resolves_absolute_rustup_home() -> io::Result<()> { + let cwd = env::current_dir()?; + let rustup_home = cwd.join("legacy"); + let home_dir = cwd.join("home"); + let mut vars = HashMap::new(); + vars.env("RUSTUP_HOME", &rustup_home); + vars.env("HOME", &home_dir); + + let tp = TestProcess::new(&cwd, &[] as &[&str], vars, ""); + for category in [ + HomeCategory::Cache, + HomeCategory::Config, + HomeCategory::Data, + HomeCategory::State, + ] { + assert_eq!(category_home(category, &tp.process)?, rustup_home); + } + Ok(()) + } + + #[test] + fn category_home_resolves_relative_rustup_home() -> io::Result<()> { + let cwd = env::current_dir()?; + let rustup_home = "rustup"; + let mut vars = HashMap::new(); + vars.env("RUSTUP_HOME", rustup_home); + vars.env("HOME", cwd.join("home")); + + let tp = TestProcess::new(&cwd, &[] as &[&str], vars, ""); + let expected = cwd.join("rustup"); + for category in [ + HomeCategory::Cache, + HomeCategory::Config, + HomeCategory::Data, + HomeCategory::State, + ] { + assert_eq!(category_home(category, &tp.process)?, expected); + } + Ok(()) + } + + #[test] + fn bin_home_resolves_explicit_override() -> io::Result<()> { + let cwd = env::current_dir()?; + for explicit_override in [cwd.join("tools"), "tools".into()] { + let mut vars = HashMap::new(); + vars.env("RUSTUP_BIN_HOME", &explicit_override); + vars.env("CARGO_HOME", cwd.join("cargo")); + vars.env("HOME", cwd.join("home")); + + let tp = TestProcess::new(&cwd, &[] as &[&str], vars, ""); + assert_eq!(bin_home(&tp.process)?, explicit_override); + } + Ok(()) + } + + #[test] + fn bin_home_resolves_absolute_cargo_home() -> io::Result<()> { + let cwd = env::current_dir()?; + let cargo_home = cwd.join("cargo-home"); + let mut vars = HashMap::new(); + vars.env("CARGO_HOME", &cargo_home); + vars.env("HOME", cwd.join("home")); + + let tp = TestProcess::new(&cwd, &[] as &[&str], vars, ""); + assert_eq!(bin_home(&tp.process)?, cargo_home.join("bin")); + Ok(()) + } + + #[test] + fn bin_home_resolves_relative_cargo_home() -> io::Result<()> { + let cwd = env::current_dir()?; + let mut vars = HashMap::new(); + vars.env("CARGO_HOME", "cargo"); + vars.env("HOME", cwd.join("home")); + + let tp = TestProcess::new(&cwd, &[] as &[&str], vars, ""); + assert_eq!(bin_home(&tp.process)?, cwd.join("cargo/bin")); + Ok(()) + } + + #[test] + fn bin_home_resolves_platform_default() -> io::Result<()> { + let cwd = env::current_dir()?; + let home_dir = cwd.join("home"); + let mut vars = HashMap::new(); + vars.env("HOME", &home_dir); + + let tp = TestProcess::new(&cwd, &[] as &[&str], vars, ""); + assert_eq!(bin_home(&tp.process)?, home_dir.join(".local/bin")); + Ok(()) + } + + #[test] + fn bin_home_without_home_errors() { + let tp = TestProcess::default(); + let error = bin_home(&tp.process).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::Other); + assert_eq!(error.to_string(), "could not find home dir"); + } +} diff --git a/src/process/home/unix.rs b/src/process/home/unix.rs new file mode 100644 index 0000000000..3204d5fc4e --- /dev/null +++ b/src/process/home/unix.rs @@ -0,0 +1,175 @@ +//! Unix XDG platform defaults. +//! +//! Empty and relative XDG values are ignored; defaults are relative to the +//! user home directory returned by [`Env::home_dir`]. + +use std::{ + io::{self, Result}, + path::PathBuf, +}; + +use home::env::Env; +use tracing::warn; + +use super::{HomeCategory, path_from_env}; + +/// See . +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(home_dir) = env.home_dir() else { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "home directory is not set", + )); + }; + + let fallback = home_dir.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::{collections::HashMap, env, path::Path}; + + use super::*; + use crate::{process::TestProcess, test::Env as _}; + + #[test] + fn category_dir_resolves_absolute_xdg_home() -> Result<()> { + let cwd = env::current_dir()?; + for category in [ + HomeCategory::Cache, + HomeCategory::Config, + HomeCategory::Data, + HomeCategory::State, + ] { + let xdg_home = cwd.join(category.fallback_subdir()); + let mut vars = HashMap::new(); + vars.env(category.xdg_env_var(), &xdg_home); + let tp = TestProcess::new(&cwd, &[] as &[&str], vars, ""); + + assert_eq!(category_dir(category, &tp.process)?, xdg_home); + } + Ok(()) + } + + #[test] + fn category_dir_resolves_platform_default() -> Result<()> { + let cwd = env::current_dir()?; + let mut vars = HashMap::new(); + vars.env("HOME", &cwd); + let tp = TestProcess::new(&cwd, &[] as &[&str], vars, ""); + + for category in [ + HomeCategory::Cache, + HomeCategory::Config, + HomeCategory::Data, + HomeCategory::State, + ] { + assert_eq!( + category_dir(category, &tp.process)?, + cwd.join(category.fallback_subdir()) + ); + } + Ok(()) + } + + #[test] + fn category_dir_ignores_relative_xdg_home() -> Result<()> { + let cwd = env::current_dir()?; + let relative_dir = "theory/of/relativity"; + for category in [ + HomeCategory::Cache, + HomeCategory::Config, + HomeCategory::Data, + HomeCategory::State, + ] { + let key = category.xdg_env_var(); + let mut vars = HashMap::new(); + vars.env("HOME", &cwd); + vars.env(key, relative_dir); + let tp = TestProcess::new(&cwd, &[] as &[&str], vars, ""); + let expected = cwd.join(category.fallback_subdir()); + + assert_eq!(category_dir(category, &tp.process)?, expected); + assert_eq!( + tp.stderr(), + format!( + "warn: ignoring relative {key} path {relative_dir}; falling back to {}\n", + expected.display() + ) + .as_bytes() + ); + } + Ok(()) + } + + #[test] + fn category_dir_resolves_relative_home() -> Result<()> { + let cwd = env::current_dir()?; + let home_dir = Path::new("theory/of/relativity"); + let mut vars = HashMap::new(); + vars.env("HOME", home_dir); + let tp = TestProcess::new(&cwd, &[] as &[&str], vars, ""); + + for category in [ + HomeCategory::Cache, + HomeCategory::Config, + HomeCategory::Data, + HomeCategory::State, + ] { + assert_eq!( + category_dir(category, &tp.process)?, + home_dir.join(category.fallback_subdir()) + ); + } + Ok(()) + } + + #[test] + fn category_dir_without_home_errors() { + let tp = TestProcess::default(); + + for category in [ + HomeCategory::Cache, + HomeCategory::Config, + HomeCategory::Data, + HomeCategory::State, + ] { + let error = category_dir(category, &tp.process).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::NotFound); + assert_eq!(error.to_string(), "home directory is not set"); + } + } +} diff --git a/src/process/home/windows.rs b/src/process/home/windows.rs new file mode 100644 index 0000000000..4e0f857058 --- /dev/null +++ b/src/process/home/windows.rs @@ -0,0 +1,70 @@ +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::*; + use crate::process::TestProcess; + + #[test] + fn category_dir_resolves_platform_default() -> io::Result<()> { + let tp = TestProcess::default(); + let local = known_folder(&FOLDERID_LocalAppData)?; + let roaming = known_folder(&FOLDERID_RoamingAppData)?; + + assert_eq!(category_dir(HomeCategory::Cache, &tp.process)?, local); + for category in [ + HomeCategory::Config, + HomeCategory::Data, + HomeCategory::State, + ] { + assert_eq!(category_dir(category, &tp.process)?, roaming); + } + Ok(()) + } +} From effec8993ea95300b57b352ade74a3e0e24c39d1 Mon Sep 17 00:00:00 2001 From: Cloud0310 <60375730+Cloud0310@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:14:50 +0800 Subject: [PATCH 03/14] fix(toolchain): preserve legacy home forwarding across home modes --- src/toolchain.rs | 4 +- tests/suite/cli_misc.rs | 102 ++++++++++++++++++++++++++++ tests/suite/cli_rustup.rs | 135 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 240 insertions(+), 1 deletion(-) diff --git a/src/toolchain.rs b/src/toolchain.rs index 1fa1275956..88951a66c4 100644 --- a/src/toolchain.rs +++ b/src/toolchain.rs @@ -154,7 +154,9 @@ impl<'a> Toolchain<'a> { 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_dir); + } } /// Apply the appropriate LD path for a command being run from a toolchain. diff --git a/tests/suite/cli_misc.rs b/tests/suite/cli_misc.rs index 46d748ada4..277e794855 100644 --- a/tests/suite/cli_misc.rs +++ b/tests/suite/cli_misc.rs @@ -487,6 +487,108 @@ async fn rustup_doesnt_prepend_path_unnecessarily() { ); } +#[tokio::test] +async fn rustup_doesnt_prepend_path_unnecessarily_with_category_mode_enabled() { + let cx = CliTestContext::new(Scenario::SimpleV2).await; + cx.config + .expect_with_env( + ["rustup", "default", "nightly"], + [("RUSTUP_USE_CATEGORY_HOME", "1")], + ) + .await + .is_ok(); + + let assert_ok_with_paths = |assert: &Assert, data| { + assert.is_ok(); + let stderr = std::env::split_paths(&assert.output.stderr) + .format_with("\n", |p, f| f(&p.display())) + .to_string(); + let stderr = assert.redact(&stderr); + snapbox::assert_data_eq!(stderr, data); + }; + + // For all of these, CARGO_HOME/bin will be auto-prepended. + let cargo_home_bin = cx.config.cargodir.join("bin"); + assert_ok_with_paths( + cx.config + .expect_with_env( + ["cargo", "--echo-env", "PATH"], + [("RUSTUP_USE_CATEGORY_HOME", "1")], + ) + .await + .extend_redactions([("[CARGO_HOME_BIN]", &cargo_home_bin)]), + snapbox::str![[r#" +[CARGO_HOME_BIN] +... +"#]], + ); + + assert_ok_with_paths( + cx.config + .expect_with_env( + ["cargo", "--echo-env", "PATH"], + [("PATH", ""), ("RUSTUP_USE_CATEGORY_HOME", "1")], + ) + .await + .extend_redactions([("[CARGO_HOME_BIN]", &cargo_home_bin)]), + snapbox::str![[r#" +[CARGO_HOME_BIN] +... +"#]], + ); + + // Check that CARGO_HOME/bin is prepended to path. + assert_ok_with_paths( + cx.config + .expect_with_env( + ["cargo", "--echo-env", "PATH"], + [ + ("PATH", &*cx.config.exedir.display().to_string()), + ("RUSTUP_USE_CATEGORY_HOME", "1"), + ], + ) + .await + .extend_redactions([ + ("[CARGO_HOME_BIN]", &cargo_home_bin), + ("[EXEDIR]", &cx.config.exedir), + ]), + snapbox::str![[r#" +[CARGO_HOME_BIN] +[EXEDIR] +... +"#]], + ); + + // But if CARGO_HOME/bin is already on PATH, it will not be prepended again, + // so exedir will take precedence. + assert_ok_with_paths( + cx.config + .expect_with_env( + ["cargo", "--echo-env", "PATH"], + [ + ( + "PATH", + std::env::join_paths([&cx.config.exedir, &cargo_home_bin]) + .unwrap() + .to_str() + .unwrap(), + ), + ("RUSTUP_USE_CATEGORY_HOME", "1"), + ], + ) + .await + .extend_redactions([ + ("[CARGO_HOME_BIN]", &cargo_home_bin), + ("[EXEDIR]", &cx.config.exedir), + ]), + snapbox::str![[r#" +[EXEDIR] +[CARGO_HOME_BIN] +... +"#]], + ); +} + #[tokio::test] async fn rustup_failed_path_search() { let cx = CliTestContext::new(Scenario::SimpleV2).await; diff --git a/tests/suite/cli_rustup.rs b/tests/suite/cli_rustup.rs index b5bbd98fd3..bf78fd4af6 100644 --- a/tests/suite/cli_rustup.rs +++ b/tests/suite/cli_rustup.rs @@ -824,6 +824,77 @@ async fn fallback_cargo_calls_correct_rustc() { .is_ok(); } +#[tokio::test] +async fn fallback_cargo_calls_correct_rustc_with_category_mode_enabled() { + let cx = CliTestContext::new(Scenario::SimpleV2).await; + // 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}")); + let cargo_bin_path = cx.config.cargodir.join("bin"); + fs::create_dir_all(&cargo_bin_path).unwrap(); + let rustc_path = cargo_bin_path.join(format!("rustc{EXE_SUFFIX}")); + fs::hard_link(rustup_path, &rustc_path).unwrap(); + + // Install a custom toolchain and a nightly toolchain for the cargo fallback + let path = cx.config.customdir.join("custom-1"); + let path = path.to_string_lossy(); + cx.config + .expect_with_env( + ["rustup", "toolchain", "link", "custom", &path], + [("RUSTUP_USE_CATEGORY_HOME", "1")], + ) + .await + .is_ok(); + cx.config + .expect_with_env( + ["rustup", "default", "custom"], + [("RUSTUP_USE_CATEGORY_HOME", "1")], + ) + .await + .is_ok(); + cx.config + .expect_with_env( + ["rustup", "update", "nightly"], + [("RUSTUP_USE_CATEGORY_HOME", "1")], + ) + .await + .is_ok(); + cx.config + .expect_with_env(["rustc", "--version"], [("RUSTUP_USE_CATEGORY_HOME", "1")]) + .await + .with_stdout(snapbox::str![[r#" +1.0.0 (hash-c-1) + +"#]]) + .is_ok(); + cx.config + .expect_with_env(["cargo", "--version"], [("RUSTUP_USE_CATEGORY_HOME", "1")]) + .await + .with_stdout(snapbox::str![[r#" +1.3.0 (hash-nightly-2) + +"#]]) + .is_ok(); + + assert!(rustc_path.exists()); + + // Here --call-rustc tells the mock cargo bin to exec `rustc --version`. + // We should be ultimately calling the custom rustc, according to the + // RUSTUP_TOOLCHAIN variable set by the original "cargo" proxy, and + // interpreted by the nested "rustc" proxy. + cx.config + .expect_with_env( + ["cargo", "--call-rustc"], + [("RUSTUP_USE_CATEGORY_HOME", "1")], + ) + .await + .with_stdout(snapbox::str![[r#" +1.0.0 (hash-c-1) + +"#]]) + .is_ok(); +} + // Checks that cargo can recursively invoke itself with rustup shorthand (via // the proxy). // @@ -891,6 +962,70 @@ error: infinite recursion detected .is_err(); } +#[tokio::test] +async fn recursive_cargo_with_category_mode_enabled() { + let cx = CliTestContext::new(Scenario::ArchivesV2).await; + cx.config + .expect_with_env( + ["rustup", "default", "nightly"], + [("RUSTUP_USE_CATEGORY_HOME", "1")], + ) + .await + .is_ok(); + + // We need an intermediary to run cargo itself. + // The "mock" cargo can't do that because on Windows it will check + // for a `cargo.exe` in the current directory before checking PATH. + // + // The solution here is to copy from the "mock" `cargo.exe` into + // `~/.cargo/bin/cargo-foo`. This is just for convenience to avoid + // needing to build another executable just for this test. + let which_cargo = cx + .config + .expect_with_env( + ["rustup", "which", "cargo"], + [("RUSTUP_USE_CATEGORY_HOME", "1")], + ) + .await; + let real_mock_cargo = which_cargo.output.stdout.trim(); + let cargo_bin_path = cx.config.cargodir.join("bin"); + let cargo_subcommand = cargo_bin_path.join(format!("cargo-foo{EXE_SUFFIX}")); + fs::create_dir_all(&cargo_bin_path).unwrap(); + fs::copy(real_mock_cargo, cargo_subcommand).unwrap(); + + cx.config + .expect_with_env( + ["cargo", "--recursive-cargo-subcommand"], + [("RUSTUP_USE_CATEGORY_HOME", "1")], + ) + .await + .with_stdout(snapbox::str![[r#" +1.3.0 (hash-nightly-2) + +"#]]) + .is_ok(); + + // If we have set the current recursion count to the maximum while doing + // another recursion, then rustup should bail out warning about it. + cx.config + .expect_with_env( + ["cargo", "--recursive-cargo-subcommand"], + [ + ( + "RUST_RECURSION_COUNT", + &*RUST_RECURSION_COUNT_MAX.to_string(), + ), + ("RUSTUP_USE_CATEGORY_HOME", "1"), + ], + ) + .await + .with_stderr(snapbox::str![[r#" +error: infinite recursion detected +... +"#]]) + .is_err(); +} + #[tokio::test] async fn show_home() { let cx = CliTestContext::new(Scenario::None).await; From 08821296e34d5e9279aedddfd44bc39d91cd445e Mon Sep 17 00:00:00 2001 From: Cloud0310 <60375730+Cloud0310@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:40:00 +0800 Subject: [PATCH 04/14] test(install): skip unrelated PATH check in settings warning test --- tests/suite/cli_inst_interactive.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/suite/cli_inst_interactive.rs b/tests/suite/cli_inst_interactive.rs index 4f94466b5c..fd3fc494eb 100644 --- a/tests/suite/cli_inst_interactive.rs +++ b/tests/suite/cli_inst_interactive.rs @@ -718,10 +718,7 @@ version = "12""#, cx.config .expect_with_env( ["rustup-init", "-y", "--no-modify-path"], - [ - ("RUSTUP_INIT_SKIP_PATH_CHECK", "no"), - ("RUSTUP_HOME", temp_dir_path), - ], + [("RUSTUP_HOME", temp_dir_path)], ) .await .is_ok() From 1e87b6a53df4d87b8a5e5fac29f216ca9e1dc3ba Mon Sep 17 00:00:00 2001 From: Cloud0310 <60375730+Cloud0310@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:29:36 +0800 Subject: [PATCH 05/14] fix(install): exclude resolved Cargo bin from PATH checks --- src/cli/self_update.rs | 31 +++++++++++++------------------ 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/src/cli/self_update.rs b/src/cli/self_update.rs index fa1a094444..bab8ddf25b 100644 --- a/src/cli/self_update.rs +++ b/src/cli/self_update.rs @@ -37,7 +37,7 @@ use std::{ env::{self, consts::EXE_SUFFIX}, fmt, fs, io::{self, Write}, - path::{Component, MAIN_SEPARATOR, Path, PathBuf}, + path::{MAIN_SEPARATOR, Path, PathBuf}, process::Command, str::FromStr, }; @@ -630,26 +630,21 @@ impl fmt::Display for HomeDisplay<'_> { } fn rustc_or_cargo_exists_in_path(process: &Process) -> anyhow::Result<()> { - // Ignore rustc and cargo if present in $HOME/.cargo/bin or a few other directories - #[allow(clippy::ptr_arg)] - fn ignore_paths(path: &PathBuf) -> bool { - !path - .components() - .any(|c| c == Component::Normal(".cargo".as_ref())) - } - - if let Some(paths) = process.var_os("PATH") { - let paths = env::split_paths(&paths).filter(ignore_paths); + let Some(paths) = process.var_os("PATH") else { + return Ok(()); + }; + let cargo_bin = process.cargo_home()?.join("bin"); - for path in paths { - let rustc = path.join(format!("rustc{EXE_SUFFIX}")); - let cargo = path.join(format!("cargo{EXE_SUFFIX}")); + let conflict = env::split_paths(&paths).find(|path| { + path != &cargo_bin + && (path.join(format!("rustc{EXE_SUFFIX}")).exists() + || path.join(format!("cargo{EXE_SUFFIX}")).exists()) + }); - if rustc.exists() || cargo.exists() { - return Err(anyhow!("{}", path.to_str().unwrap().to_owned())); - } - } + if let Some(path) = conflict { + anyhow::bail!("{}", path.display()); } + Ok(()) } From ceebe69a28ff13f8f994362a3941445f2d63d536 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 06/14] feat(shell): use env home for installation scripts and setup --- src/cli/self_update.rs | 21 ++++++++++++--------- src/cli/self_update/unix.rs | 15 +++++++++------ src/process.rs | 16 ++++++++++++++++ src/test/clitools.rs | 2 ++ tests/suite/cli_inst_interactive.rs | 26 ++++++++++++++++++++++++++ tests/suite/cli_paths.rs | 16 ++++++++++++++++ 6 files changed, 81 insertions(+), 15 deletions(-) diff --git a/src/cli/self_update.rs b/src/cli/self_update.rs index bab8ddf25b..6c7e94f96f 100644 --- a/src/cli/self_update.rs +++ b/src/cli/self_update.rs @@ -213,15 +213,18 @@ impl InstallOpts<'_> { }; 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, home_dir.as_deref()), - ), - ); + { + let env_home = process.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, home_dir.as_deref()), + ), + ); + } #[cfg(unix)] warn_if_default_linker_missing(process); diff --git a/src/cli/self_update/unix.rs b/src/cli/self_update/unix.rs index e2ab288f1a..39f4d9e2b2 100644 --- a/src/cli/self_update/unix.rs +++ b/src/cli/self_update/unix.rs @@ -63,10 +63,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 env_home = process.env_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(&env_home, home_dir.as_deref())?; let source_cmd_with_newline = format!("\n{source_cmd}"); for rc in sh.rcs(process) { @@ -88,22 +88,25 @@ pub(crate) fn add_to_path(process: &Process) -> anyhow::Result<()> { } } - remove_legacy_paths(process, &cargo_home, home_dir.as_deref())?; + if !process.use_category_home() { + remove_legacy_paths(process, &env_home, home_dir.as_deref())?; + } Ok(()) } pub(crate) fn write_env_files(process: &Process) -> anyhow::Result<()> { - let cargo_home = process.cargo_home()?; - let bin_dir = cargo_home.join("bin"); + let env_home = process.env_home()?; + let bin_dir = process.cargo_home()?.join("bin"); let home_dir = process.home_dir(); + utils::ensure_dir_exists("env", &env_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_dir, home_dir.as_deref())?; + sh.write_script(&script, &env_home, &bin_dir, home_dir.as_deref())?; written.push(script); } } diff --git a/src/process.rs b/src/process.rs index 082dfd79cf..a74a8b9251 100644 --- a/src/process.rs +++ b/src/process.rs @@ -98,6 +98,18 @@ impl Process { }) } + /// Returns the directory containing Rustup's shell environment scripts. + /// + /// Category mode uses the data home; legacy mode uses the Cargo home. + #[cfg(any(unix, test))] + pub(crate) fn env_home(&self) -> io::Result { + if self.use_category_home() { + return home::category_home(home::HomeCategory::Data, self); + } + + home_env::cargo_home_with_env(self) + } + /// Returns Rustup's binary installation directory. /// /// Category mode uses the binary directory resolver in [`home`]. @@ -523,6 +535,7 @@ mod tests { for home in [&homes.cache, &homes.config, &homes.data, &homes.state] { assert_eq!(home, &home_dir.join(".rustup")); } + assert_eq!(tp.process.env_home()?, home_dir.join(".cargo")); assert_eq!(tp.process.bin_home()?, home_dir.join(".cargo/bin")); let rustup_home = home_dir.join("legacy"); @@ -534,6 +547,7 @@ mod tests { for home in [&homes.cache, &homes.config, &homes.data, &homes.state] { assert_eq!(home, &rustup_home); } + assert_eq!(tp.process.env_home()?, cargo_home); assert_eq!(tp.process.bin_home()?, cargo_home.join("bin")); Ok(()) } @@ -556,6 +570,7 @@ mod tests { assert_eq!(homes.config, home_dir.join(".config/rustup")); assert_eq!(homes.data, home_dir.join(".local/share/rustup")); assert_eq!(homes.state, home_dir.join(".local/state/rustup")); + assert_eq!(tp.process.env_home()?, home_dir.join(".local/share/rustup")); } assert_eq!(tp.process.bin_home()?, home_dir.join(".local/bin")); @@ -572,6 +587,7 @@ mod tests { assert_eq!(homes.config, Path::new("config")); assert_eq!(homes.data, Path::new("data")); assert_eq!(homes.state, Path::new("state")); + assert_eq!(tp.process.env_home()?, Path::new("data")); assert_eq!(tp.process.bin_home()?, Path::new("bin")); Ok(()) } diff --git a/src/test/clitools.rs b/src/test/clitools.rs index b5268216b2..f6a7708db1 100644 --- a/src/test/clitools.rs +++ b/src/test/clitools.rs @@ -802,6 +802,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_DATA_HOME"); env::remove_var("RUSTUP_TOOLCHAIN"); env::remove_var("SHELL"); env::remove_var("ZDOTDIR"); @@ -811,6 +812,7 @@ 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_DATA_HOME"); match env::var("RUSTUP_BACKTRACE") { Ok(val) => env::set_var("RUST_BACKTRACE", val), diff --git a/tests/suite/cli_inst_interactive.rs b/tests/suite/cli_inst_interactive.rs index fd3fc494eb..89116a5a88 100644 --- a/tests/suite/cli_inst_interactive.rs +++ b/tests/suite/cli_inst_interactive.rs @@ -179,6 +179,32 @@ Consider running the right command for your shell (note the leading DOT): }); } +#[cfg(unix)] +#[tokio::test] +async fn category_install_displays_shell_setup() { + let cx = CliTestContext::new(Scenario::Empty).await; + let data_home = cx.config.homedir.join("data"); + let env = [ + ("RUSTUP_USE_CATEGORY_HOME", "1"), + ("RUSTUP_DATA_HOME", data_home.to_str().unwrap()), + ("PATH", cx.config.exedir.to_str().unwrap()), + ("SHELL", "/bin/sh"), + ]; + + cx.config + .expect_with_env(["rustup-init", "-y", "--default-toolchain", "none"], env) + .await + .extend_redactions([("[DATA_HOME]", data_home.to_str().unwrap().to_owned())]) + .is_ok() + .with_stdout(snapbox::str![[r#" +... +corresponding env file under $HOME/data. +... + . "[DATA_HOME]/env" # For sh/ash/dash/pdksh +... +"#]]); +} + #[tokio::test] async fn blank_lines_around_stderr_log_output_update() { let cx = CliTestContext::new(Scenario::SimpleV2).await; diff --git a/tests/suite/cli_paths.rs b/tests/suite/cli_paths.rs index cc723459ef..239f61ec87 100644 --- a/tests/suite/cli_paths.rs +++ b/tests/suite/cli_paths.rs @@ -69,6 +69,22 @@ export PATH="$HOME/apple/bin" } } + #[tokio::test] + async fn category_install_creates_env_in_data_home() { + let cx = CliTestContext::new(Scenario::Empty).await; + let data_home = cx.config.homedir.join("data"); + let env = [ + ("RUSTUP_USE_CATEGORY_HOME", "1"), + ("RUSTUP_DATA_HOME", data_home.to_str().unwrap()), + ("PATH", cx.config.exedir.to_str().unwrap()), + ("SHELL", "/bin/sh"), + ]; + + cx.config.expect_with_env(INIT_NONE, env).await.is_ok(); + + assert!(data_home.join("env").is_file()); + } + #[tokio::test] async fn install_updates_bash_rcs() { let cx = CliTestContext::new(Scenario::Empty).await; From 7ae5654bcf02de5a1590c03419b47b7d1fa87569 Mon Sep 17 00:00:00 2001 From: Cloud0310 <60375730+Cloud0310@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:15:27 +0800 Subject: [PATCH 07/14] feat(cache): store cached files in the cache home --- src/cli/self_update.rs | 3 ++ src/config.rs | 14 ++++++-- src/dist/download.rs | 2 +- src/process.rs | 2 +- src/test/clitools.rs | 2 ++ src/toolchain.rs | 1 + tests/suite/cli_v2.rs | 75 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 95 insertions(+), 4 deletions(-) diff --git a/src/cli/self_update.rs b/src/cli/self_update.rs index 6c7e94f96f..562cfe35dd 100644 --- a/src/cli/self_update.rs +++ b/src/cli/self_update.rs @@ -270,6 +270,9 @@ impl InstallOpts<'_> { fs::create_dir_all(home).context("unable to create ~/.rustup")?; } + let cache_home = process.home_dirs()?.cache; + utils::ensure_dir_exists("cache home", &cache_home)?; + let mut cfg = Cfg::from_env(current_dir, quiet, false, process)?; let (components, targets) = (self.components, self.targets); diff --git a/src/config.rs b/src/config.rs index 1b15357ef9..a777bccb58 100644 --- a/src/config.rs +++ b/src/config.rs @@ -321,7 +321,9 @@ pub(crate) struct Cfg<'a> { fallback_settings: Option, pub toolchains_dir: PathBuf, update_hash_dir: PathBuf, + pub cache_dir: PathBuf, pub download_dir: PathBuf, + pub tmp_dir: PathBuf, pub toolchain_override: Option>, env_override: Option>, pub(crate) dist_root_server: String, @@ -348,6 +350,7 @@ impl<'a> Cfg<'a> { ) -> anyhow::Result { // Set up the rustup home directory let rustup_dir = process.rustup_home()?; + let cache_dir = process.home_dirs()?.cache; utils::ensure_dir_exists("home", &rustup_dir)?; @@ -379,8 +382,9 @@ 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 update_hash_dir = cache_dir.join("update-hashes"); + let download_dir = cache_dir.join("downloads"); + let tmp_dir = cache_dir.join("tmp"); // Environment override let env_override = match &process.var_opt("RUSTUP_TOOLCHAIN")? { @@ -399,7 +403,9 @@ impl<'a> Cfg<'a> { fallback_settings, toolchains_dir, update_hash_dir, + cache_dir, download_dir, + tmp_dir, toolchain_override: None, env_override, dist_root_server, @@ -1177,7 +1183,9 @@ impl Debug for Cfg<'_> { fallback_settings, toolchains_dir, update_hash_dir, + cache_dir, download_dir, + tmp_dir, toolchain_override, env_override, dist_root_server, @@ -1196,7 +1204,9 @@ impl Debug for Cfg<'_> { .field("fallback_settings", fallback_settings) .field("toolchains_dir", toolchains_dir) .field("update_hash_dir", update_hash_dir) + .field("cache_dir", cache_dir) .field("download_dir", download_dir) + .field("tmp_dir", tmp_dir) .field("toolchain_override", toolchain_override) .field("env_override", env_override) .field("dist_root_server", dist_root_server) diff --git a/src/dist/download.rs b/src/dist/download.rs index 01dc0d2942..e6416596fe 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.tmp_dir.clone(), cfg.dist_root_server.as_str(), )), download_dir: &cfg.download_dir, diff --git a/src/process.rs b/src/process.rs index a74a8b9251..3f4f241dc7 100644 --- a/src/process.rs +++ b/src/process.rs @@ -83,7 +83,7 @@ impl Process { /// /// Category mode resolves each directory independently through [`home`]. /// Legacy mode uses the resolved Rustup home for all four categories. - #[allow(dead_code, reason = "split-home interface is not consumed yet")] + /// See [`home`] for platform defaults and path rules. pub(crate) fn home_dirs(&self) -> io::Result { if self.use_category_home() { return HomeDirs::from_env(self); diff --git a/src/test/clitools.rs b/src/test/clitools.rs index f6a7708db1..6630bf1cc6 100644 --- a/src/test/clitools.rs +++ b/src/test/clitools.rs @@ -802,6 +802,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_DATA_HOME"); env::remove_var("RUSTUP_TOOLCHAIN"); env::remove_var("SHELL"); @@ -812,6 +813,7 @@ 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"); match env::var("RUSTUP_BACKTRACE") { diff --git a/src/toolchain.rs b/src/toolchain.rs index 88951a66c4..4fd95b1c1b 100644 --- a/src/toolchain.rs +++ b/src/toolchain.rs @@ -154,6 +154,7 @@ impl<'a> Toolchain<'a> { env_var::inc("RUST_RECURSION_COUNT", cmd, self.cfg.process); cmd.env("RUSTUP_TOOLCHAIN", format!("{}", self.name)); + cmd.env("RUSTUP_CACHE_HOME", &self.cfg.cache_dir); if !self.cfg.process.use_category_home() { cmd.env("RUSTUP_HOME", &self.cfg.rustup_dir); } diff --git a/tests/suite/cli_v2.rs b/tests/suite/cli_v2.rs index 97813807f5..74ca5849fa 100644 --- a/tests/suite/cli_v2.rs +++ b/tests/suite/cli_v2.rs @@ -5,6 +5,7 @@ use std::{fs, io::Write, path::PathBuf}; use rustup::{ dist::{TargetTuple, manifest::Manifest}, + for_host, test::{ CROSS_ARCH1, CROSS_ARCH2, CliTestContext, Config, Scenario, create_hash, this_host_tuple, }, @@ -2088,6 +2089,80 @@ warn: removing the last target; no build targets will be available .is_ok(); } +#[tokio::test] +async fn install_cache_with_category_mode_enabled() { + 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 = for_host!("stable-{}"); + let update_hash = cache_home.join("update-hashes").join(toolchain); + + 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!(cache_home.join("downloads").is_dir()); + assert!(update_hash.is_file()); + 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!(!update_hash.is_file()); +} + +#[tokio::test] +async fn install_cache_with_category_mode_disabled() { + 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 = for_host!("stable-{}"); + let update_hash = cx.config.rustupdir.join("update-hashes").join(toolchain); + + cx.config + .expect_with_env( + ["rustup", "toolchain", "install", "stable"], + [ + ("RUSTUP_CACHE_HOME", cache_home_env), + ("RUSTUP_USE_CATEGORY_HOME", "0"), + ], + ) + .await + .is_ok(); + + assert!(cx.config.rustupdir.join("tmp").is_dir()); + assert!(cx.config.rustupdir.join("downloads").is_dir()); + assert!(update_hash.is_file()); + 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", "0"), + ], + ) + .await + .is_ok(); + assert!(!update_hash.is_file()); +} + #[tokio::test] // Issue #304 async fn remove_target_missing_update_hash() { From 34f131618098e2d4a3524eb9fb1c22aa25360bf5 Mon Sep 17 00:00:00 2001 From: Cloud0310 <60375730+Cloud0310@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:16:10 +0800 Subject: [PATCH 08/14] feat(toolchain): store toolchains and fallback data in the data home --- src/cli/self_update.rs | 5 ++-- src/config.rs | 10 ++++++-- src/toolchain.rs | 1 + src/toolchain/channels.rs | 2 +- tests/suite/cli_rustup.rs | 39 ++++++++++++++++--------------- tests/suite/cli_v2.rs | 48 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 80 insertions(+), 25 deletions(-) diff --git a/src/cli/self_update.rs b/src/cli/self_update.rs index 562cfe35dd..7e87eb8fa7 100644 --- a/src/cli/self_update.rs +++ b/src/cli/self_update.rs @@ -270,8 +270,9 @@ impl InstallOpts<'_> { fs::create_dir_all(home).context("unable to create ~/.rustup")?; } - let cache_home = process.home_dirs()?.cache; - utils::ensure_dir_exists("cache home", &cache_home)?; + let home_dirs = process.home_dirs()?; + utils::ensure_dir_exists("cache home", &home_dirs.cache)?; + utils::ensure_dir_exists("data home", &home_dirs.data)?; let mut cfg = Cfg::from_env(current_dir, quiet, false, process)?; diff --git a/src/config.rs b/src/config.rs index a777bccb58..500e615fb7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -322,6 +322,7 @@ pub(crate) struct Cfg<'a> { pub toolchains_dir: PathBuf, update_hash_dir: PathBuf, pub cache_dir: PathBuf, + pub data_dir: PathBuf, pub download_dir: PathBuf, pub tmp_dir: PathBuf, pub toolchain_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 cache_dir = process.home_dirs()?.cache; + let home_dirs = process.home_dirs()?; + let cache_dir = home_dirs.cache; + let 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 = data_dir.join("toolchains"); let update_hash_dir = cache_dir.join("update-hashes"); let download_dir = cache_dir.join("downloads"); let tmp_dir = cache_dir.join("tmp"); @@ -404,6 +407,7 @@ impl<'a> Cfg<'a> { toolchains_dir, update_hash_dir, cache_dir, + data_dir, download_dir, tmp_dir, toolchain_override: None, @@ -1184,6 +1188,7 @@ impl Debug for Cfg<'_> { toolchains_dir, update_hash_dir, cache_dir, + data_dir, download_dir, tmp_dir, toolchain_override, @@ -1205,6 +1210,7 @@ impl Debug for Cfg<'_> { .field("toolchains_dir", toolchains_dir) .field("update_hash_dir", update_hash_dir) .field("cache_dir", cache_dir) + .field("data_dir", data_dir) .field("download_dir", download_dir) .field("tmp_dir", tmp_dir) .field("toolchain_override", toolchain_override) diff --git a/src/toolchain.rs b/src/toolchain.rs index 4fd95b1c1b..3eeebf8c72 100644 --- a/src/toolchain.rs +++ b/src/toolchain.rs @@ -155,6 +155,7 @@ impl<'a> Toolchain<'a> { cmd.env("RUSTUP_TOOLCHAIN", format!("{}", self.name)); cmd.env("RUSTUP_CACHE_HOME", &self.cfg.cache_dir); + cmd.env("RUSTUP_DATA_HOME", &self.cfg.data_dir); if !self.cfg.process.use_category_home() { cmd.env("RUSTUP_HOME", &self.cfg.rustup_dir); } diff --git a/src/toolchain/channels.rs b/src/toolchain/channels.rs index 3429f7da71..c3d4e27f59 100644 --- a/src/toolchain/channels.rs +++ b/src/toolchain/channels.rs @@ -226,7 +226,7 @@ impl<'a> ChannelToolchain<'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.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 bf78fd4af6..5d0d8a5cee 100644 --- a/tests/suite/cli_rustup.rs +++ b/tests/suite/cli_rustup.rs @@ -769,7 +769,7 @@ custom async fn fallback_cargo_calls_correct_rustc() { let cx = CliTestContext::new(Scenario::SimpleV2).await; // Hm, this is the _only_ test that assumes that toolchain proxies - // exist in CARGO_HOME. Adding that proxy here. + // exist in bin home. Adding that proxy here. let rustup_path = cx.config.exedir.join(format!("rustup{EXE_SUFFIX}")); let cargo_bin_path = cx.config.cargodir.join("bin"); fs::create_dir_all(&cargo_bin_path).unwrap(); @@ -827,8 +827,14 @@ async fn fallback_cargo_calls_correct_rustc() { #[tokio::test] async fn fallback_cargo_calls_correct_rustc_with_category_mode_enabled() { 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 data_env = [ + ("RUSTUP_DATA_HOME", data_home_env), + ("RUSTUP_USE_CATEGORY_HOME", "1"), + ]; // Hm, this is the _only_ test that assumes that toolchain proxies - // exist in CARGO_HOME. Adding that proxy here. + // exist in bin home. Adding that proxy here. let rustup_path = cx.config.exedir.join(format!("rustup{EXE_SUFFIX}")); let cargo_bin_path = cx.config.cargodir.join("bin"); fs::create_dir_all(&cargo_bin_path).unwrap(); @@ -839,28 +845,19 @@ async fn fallback_cargo_calls_correct_rustc_with_category_mode_enabled() { let path = cx.config.customdir.join("custom-1"); let path = path.to_string_lossy(); cx.config - .expect_with_env( - ["rustup", "toolchain", "link", "custom", &path], - [("RUSTUP_USE_CATEGORY_HOME", "1")], - ) + .expect_with_env(["rustup", "toolchain", "link", "custom", &path], data_env) .await .is_ok(); cx.config - .expect_with_env( - ["rustup", "default", "custom"], - [("RUSTUP_USE_CATEGORY_HOME", "1")], - ) + .expect_with_env(["rustup", "default", "custom"], data_env) .await .is_ok(); cx.config - .expect_with_env( - ["rustup", "update", "nightly"], - [("RUSTUP_USE_CATEGORY_HOME", "1")], - ) + .expect_with_env(["rustup", "update", "nightly"], data_env) .await .is_ok(); cx.config - .expect_with_env(["rustc", "--version"], [("RUSTUP_USE_CATEGORY_HOME", "1")]) + .expect_with_env(["rustc", "--version"], data_env) .await .with_stdout(snapbox::str![[r#" 1.0.0 (hash-c-1) @@ -868,7 +865,7 @@ async fn fallback_cargo_calls_correct_rustc_with_category_mode_enabled() { "#]]) .is_ok(); cx.config - .expect_with_env(["cargo", "--version"], [("RUSTUP_USE_CATEGORY_HOME", "1")]) + .expect_with_env(["cargo", "--version"], data_env) .await .with_stdout(snapbox::str![[r#" 1.3.0 (hash-nightly-2) @@ -883,16 +880,18 @@ async fn fallback_cargo_calls_correct_rustc_with_category_mode_enabled() { // RUSTUP_TOOLCHAIN variable set by the original "cargo" proxy, and // interpreted by the nested "rustc" proxy. cx.config - .expect_with_env( - ["cargo", "--call-rustc"], - [("RUSTUP_USE_CATEGORY_HOME", "1")], - ) + .expect_with_env(["cargo", "--call-rustc"], data_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()); + } } // 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 74ca5849fa..f88777b166 100644 --- a/tests/suite/cli_v2.rs +++ b/tests/suite/cli_v2.rs @@ -2163,6 +2163,54 @@ async fn install_cache_with_category_mode_disabled() { assert!(!update_hash.is_file()); } +#[tokio::test] +async fn install_data_with_category_mode_enabled() { + 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 = for_host!("stable-{}"); + + 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()); +} + +#[tokio::test] +async fn install_data_with_category_mode_disabled() { + 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 = for_host!("stable-{}"); + + cx.config + .expect_with_env( + ["rustup", "toolchain", "install", "stable"], + [ + ("RUSTUP_DATA_HOME", data_home_env), + ("RUSTUP_USE_CATEGORY_HOME", "0"), + ], + ) + .await + .is_ok(); + + assert!( + cx.config + .rustupdir + .join("toolchains") + .join(toolchain) + .is_dir() + ); +} + #[tokio::test] // Issue #304 async fn remove_target_missing_update_hash() { From b9f6cd9114c11b365aaff6c1e2c775b50cd806d5 Mon Sep 17 00:00:00 2001 From: Cloud0310 <60375730+Cloud0310@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:16:53 +0800 Subject: [PATCH 09/14] feat(state): store runtime state in the state home --- src/cli/self_update/stage.rs | 24 +++++++++++++++++++++++- src/config.rs | 10 +++++++++- src/test/clitools.rs | 2 ++ src/toolchain.rs | 1 + tests/suite/cli_rustup.rs | 33 +++++++++++++++++++++++++++++++++ tests/suite/cli_self_upd.rs | 8 ++++---- 6 files changed, 72 insertions(+), 6 deletions(-) diff --git a/src/cli/self_update/stage.rs b/src/cli/self_update/stage.rs index a8e99c52bc..e0434c9a66 100644 --- a/src/cli/self_update/stage.rs +++ b/src/cli/self_update/stage.rs @@ -280,7 +280,7 @@ pub fn updater_path(stage: &Path) -> PathBuf { } fn stage_root(process: &Process) -> anyhow::Result { - Ok(process.rustup_home()?.join(SELF_UPDATE_DIRECTORY)) + Ok(process.home_dirs()?.state.join(SELF_UPDATE_DIRECTORY)) } pub const SELF_UPDATE_DIRECTORY: &str = "self-update"; @@ -315,6 +315,28 @@ mod tests { assert!(!Marker::Complete.path(&stage).exists()); } + #[tokio::test] + async fn updater_path_is_stable_with_category_mode_enabled() { + let root = test_dir().unwrap(); + let state_home = root.path().join("state"); + let mut vars = HashMap::new(); + vars.env("RUSTUP_HOME", root.path().join("rustup")); + vars.env("RUSTUP_STATE_HOME", &state_home); + vars.env("RUSTUP_USE_CATEGORY_HOME", "1"); + let process = TestProcess::with_vars(vars); + let first = SelfUpdateLock::lock(&process.process).unwrap(); + let first_path = updater_path(&first.directory); + let stage = first.directory.clone(); + assert_eq!(stage, state_home.join(SELF_UPDATE_DIRECTORY)); + fs::write(&first_path, "").unwrap(); + fs::write(Marker::Complete.path(&stage), "").unwrap(); + drop(first); + let second = prepared_updater(&process); + + assert_eq!(first_path, *second); + assert!(!Marker::Complete.path(&stage).exists()); + } + #[tokio::test] async fn self_update_lock_is_global() { let root = test_dir().unwrap(); diff --git a/src/config.rs b/src/config.rs index 500e615fb7..0e8e1d351c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -323,6 +323,7 @@ pub(crate) struct Cfg<'a> { update_hash_dir: PathBuf, pub cache_dir: PathBuf, pub data_dir: PathBuf, + pub state_dir: PathBuf, pub download_dir: PathBuf, pub tmp_dir: PathBuf, pub toolchain_override: Option>, @@ -354,8 +355,12 @@ impl<'a> Cfg<'a> { let home_dirs = process.home_dirs()?; let cache_dir = home_dirs.cache; let data_dir = home_dirs.data; + let state_dir = home_dirs.state; utils::ensure_dir_exists("home", &rustup_dir)?; + if process.use_category_home() { + utils::ensure_dir_exists("state home", &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(state_dir.join("state.toml")); // Centralised file for multi-user systems to provide admin/distributor set initial values. #[cfg(unix)] @@ -408,6 +413,7 @@ impl<'a> Cfg<'a> { update_hash_dir, cache_dir, data_dir, + state_dir, download_dir, tmp_dir, toolchain_override: None, @@ -1189,6 +1195,7 @@ impl Debug for Cfg<'_> { update_hash_dir, cache_dir, data_dir, + state_dir, download_dir, tmp_dir, toolchain_override, @@ -1211,6 +1218,7 @@ impl Debug for Cfg<'_> { .field("update_hash_dir", update_hash_dir) .field("cache_dir", cache_dir) .field("data_dir", data_dir) + .field("state_dir", state_dir) .field("download_dir", download_dir) .field("tmp_dir", tmp_dir) .field("toolchain_override", toolchain_override) diff --git a/src/test/clitools.rs b/src/test/clitools.rs index 6630bf1cc6..95aa91996e 100644 --- a/src/test/clitools.rs +++ b/src/test/clitools.rs @@ -804,6 +804,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"); @@ -815,6 +816,7 @@ async fn setup_test_state(test_dist_dir: TempDir) -> (TempDir, Config) { 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 3eeebf8c72..0597afe132 100644 --- a/src/toolchain.rs +++ b/src/toolchain.rs @@ -156,6 +156,7 @@ impl<'a> Toolchain<'a> { cmd.env("RUSTUP_TOOLCHAIN", format!("{}", self.name)); cmd.env("RUSTUP_CACHE_HOME", &self.cfg.cache_dir); cmd.env("RUSTUP_DATA_HOME", &self.cfg.data_dir); + cmd.env("RUSTUP_STATE_HOME", &self.cfg.state_dir); if !self.cfg.process.use_category_home() { cmd.env("RUSTUP_HOME", &self.cfg.rustup_dir); } diff --git a/tests/suite/cli_rustup.rs b/tests/suite/cli_rustup.rs index 5d0d8a5cee..badfb3a7fe 100644 --- a/tests/suite/cli_rustup.rs +++ b/tests/suite/cli_rustup.rs @@ -1222,6 +1222,39 @@ hint: a new stable Rust release is available, run `rustup update stable` to inst .is_ok(); } +#[tokio::test] +async fn notify_release_hint_at_most_once_per_day_with_category_mode_enabled() { + 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_with_env(["rustup", "set", "release-hint", "enable"], state_env) + .await + .is_ok(); + cx.config + .expect_with_env(["rustup", "update", "stable"], state_env) + .await + .is_ok(); + cx.config + .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 + +"#]]) + .is_ok(); + cx.config + .expect_with_env(["rustup", "show"], state_env) + .await + .with_stderr(snapbox::str![[""]]) + .is_ok(); + assert!(state_home.join("state.toml").is_file()); +} + #[tokio::test] async fn notify_release_hint_skipped_for_recent_release() { let cx = CliTestContext::new(Scenario::RecentStable).await; diff --git a/tests/suite/cli_self_upd.rs b/tests/suite/cli_self_upd.rs index 1c79ec9214..c6e2c30e30 100644 --- a/tests/suite/cli_self_upd.rs +++ b/tests/suite/cli_self_upd.rs @@ -1313,8 +1313,8 @@ async fn install_minimal_profile() { cx.config.expect_component_not_executable("cargo").await; } -fn wait_for_completed_update(rustup_home: &Path) { - let stage = rustup_home.join(SELF_UPDATE_DIRECTORY); +fn wait_for_completed_update(state_home: &Path) { + let stage = state_home.join(SELF_UPDATE_DIRECTORY); retry(Fibonacci::from_millis(1).map(jitter).take(23), || { if Marker::Complete.path(&stage).is_file() { Ok(()) @@ -1327,8 +1327,8 @@ fn wait_for_completed_update(rustup_home: &Path) { .unwrap(); } -fn managed_updater(rustup_home: &Path) -> PathBuf { - updater_path(&rustup_home.join(SELF_UPDATE_DIRECTORY)) +fn managed_updater(state_home: &Path) -> PathBuf { + updater_path(&state_home.join(SELF_UPDATE_DIRECTORY)) } const TEST_VERSION: &str = "1.1.1"; From 5c58679325b2b3cbba6b065249bdbac9b713a9e0 Mon Sep 17 00:00:00 2001 From: Cloud0310 <60375730+Cloud0310@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:18:02 +0800 Subject: [PATCH 10/14] feat(config): store settings in the config home --- src/cli/self_update.rs | 15 ++----- src/config.rs | 11 +++++- src/test/clitools.rs | 1 + src/toolchain.rs | 1 + tests/suite/cli_exact.rs | 42 ++++++++++++++++++++ tests/suite/cli_inst_interactive.rs | 61 +++++++++++++++++++++++++++++ 6 files changed, 117 insertions(+), 14 deletions(-) diff --git a/src/cli/self_update.rs b/src/cli/self_update.rs index 7e87eb8fa7..55d06f0bca 100644 --- a/src/cli/self_update.rs +++ b/src/cli/self_update.rs @@ -260,16 +260,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 home_dirs = process.home_dirs()?; utils::ensure_dir_exists("cache home", &home_dirs.cache)?; utils::ensure_dir_exists("data home", &home_dirs.data)?; @@ -683,8 +673,9 @@ 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")); + // TODO: Setting file's category is still under discussion + // See: [discussion issue](https://github.com/rust-lang/rustup/issues/4944) + 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 0e8e1d351c..9248071f02 100644 --- a/src/config.rs +++ b/src/config.rs @@ -322,6 +322,7 @@ pub(crate) struct Cfg<'a> { pub toolchains_dir: PathBuf, update_hash_dir: PathBuf, pub cache_dir: PathBuf, + pub config_dir: PathBuf, pub data_dir: PathBuf, pub 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 cache_dir = home_dirs.cache; + let config_dir = home_dirs.config; let data_dir = home_dirs.data; let state_dir = home_dirs.state; - utils::ensure_dir_exists("home", &rustup_dir)?; if process.use_category_home() { + utils::ensure_dir_exists("config home", &config_dir)?; utils::ensure_dir_exists("state home", &state_dir)?; + } else { + utils::ensure_dir_exists("home", &config_dir)?; } - let settings_file = SettingsFile::new(rustup_dir.join("settings.toml")); + let settings_file = SettingsFile::new(config_dir.join("settings.toml")); settings_file.with(|s| { debug!("read metadata version: {}", s.version); if s.version == MetadataVersion::default() { @@ -412,6 +416,7 @@ impl<'a> Cfg<'a> { toolchains_dir, update_hash_dir, cache_dir, + config_dir, data_dir, state_dir, download_dir, @@ -1194,6 +1199,7 @@ impl Debug for Cfg<'_> { toolchains_dir, update_hash_dir, cache_dir, + config_dir, data_dir, state_dir, download_dir, @@ -1217,6 +1223,7 @@ impl Debug for Cfg<'_> { .field("toolchains_dir", toolchains_dir) .field("update_hash_dir", update_hash_dir) .field("cache_dir", cache_dir) + .field("config_dir", config_dir) .field("data_dir", data_dir) .field("state_dir", state_dir) .field("download_dir", download_dir) diff --git a/src/test/clitools.rs b/src/test/clitools.rs index 95aa91996e..e8cda4faa4 100644 --- a/src/test/clitools.rs +++ b/src/test/clitools.rs @@ -803,6 +803,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 0597afe132..ace2f36a72 100644 --- a/src/toolchain.rs +++ b/src/toolchain.rs @@ -155,6 +155,7 @@ impl<'a> Toolchain<'a> { cmd.env("RUSTUP_TOOLCHAIN", format!("{}", self.name)); cmd.env("RUSTUP_CACHE_HOME", &self.cfg.cache_dir); + cmd.env("RUSTUP_CONFIG_HOME", &self.cfg.config_dir); cmd.env("RUSTUP_DATA_HOME", &self.cfg.data_dir); cmd.env("RUSTUP_STATE_HOME", &self.cfg.state_dir); if !self.cfg.process.use_category_home() { diff --git a/tests/suite/cli_exact.rs b/tests/suite/cli_exact.rs index 20ebaf71bd..da49cf3685 100644 --- a/tests/suite/cli_exact.rs +++ b/tests/suite/cli_exact.rs @@ -657,6 +657,48 @@ help: run 'rustup default stable' to download the latest stable release of Rust "#]]); } +#[tokio::test] +async fn default_config_with_category_mode_enabled() { + 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()); +} + +#[tokio::test] +async fn default_config_with_category_mode_disabled() { + 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", "0"), + ], + ) + .await + .is_ok(); + + assert!(cx.config.rustupdir.join("settings.toml").is_file()); +} + #[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 89116a5a88..94ccce37a6 100644 --- a/tests/suite/cli_inst_interactive.rs +++ b/tests/suite/cli_inst_interactive.rs @@ -359,6 +359,32 @@ no active toolchain .is_ok(); } +#[tokio::test] +async fn install_initializes_homes_with_category_mode_enabled() { + 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 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()); + assert!(data_home.is_dir()); + assert!(cache_home.is_dir()); +} + #[tokio::test] async fn with_no_toolchain_doesnt_hang() { let cx = CliTestContext::new(Scenario::SimpleV2).await; @@ -755,6 +781,41 @@ warn: it looks like you have an existing rustup settings file at: "#]]); } +#[tokio::test] +async fn install_warns_about_existing_settings_file_with_category_mode_enabled() { + 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(); + let settings_file = config_home.join("settings.toml"); + fs::create_dir_all(settings_file.parent().unwrap()).unwrap(); + raw::write_file( + &settings_file, + &format!( + r#"default_toolchain = "{}" +profile = "default" +version = "12""#, + this_host_tuple() + ), + ) + .unwrap(); + + cx.config + .expect_with_env( + ["rustup-init", "-y", "--no-modify-path"], + [ + ("RUSTUP_CONFIG_HOME", config_home_env), + ("RUSTUP_USE_CATEGORY_HOME", "1"), + ], + ) + .await + .is_ok() + .with_stderr(snapbox::str![[r#" +... +warn: it looks like you have an existing rustup settings file at: +... +"#]]); +} + #[cfg(unix)] #[tokio::test] async fn install_warns_if_default_linker_missing() { From f52b38799d24b587cf4c13338e81170a72f39bf6 Mon Sep 17 00:00:00 2001 From: Cloud0310 <60375730+Cloud0310@users.noreply.github.com> Date: Wed, 30 Sep 2026 03:23:20 +0800 Subject: [PATCH 11/14] test(installer): cover legacy home in installation hint --- tests/suite/cli_inst_interactive.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/suite/cli_inst_interactive.rs b/tests/suite/cli_inst_interactive.rs index 94ccce37a6..1ca8d3792a 100644 --- a/tests/suite/cli_inst_interactive.rs +++ b/tests/suite/cli_inst_interactive.rs @@ -70,8 +70,16 @@ async fn smoke_case_install_no_modify_path() { ("SHELL", "/bin/sh"), ], ) + .extend_redactions([("[RUSTUP_DIR]", &cx.config.rustupdir.to_string())]) .with_stdout(snapbox::str![[r#" ... +Rustup metadata and toolchains will be installed into the Rustup +home directory, located at: + + [RUSTUP_DIR] + +This can be modified with the RUSTUP_HOME environment variable. +... This path needs to be in your PATH environment variable, but will not be added automatically. From b7f25b3028e3ce0ea48301f106f1d2fd087f6062 Mon Sep 17 00:00:00 2001 From: Cloud0310 <60375730+Cloud0310@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:21:04 +0800 Subject: [PATCH 12/14] feat(installer): use bin home for installation and self-update --- src/cli/proxy_mode.rs | 2 +- src/cli/rustup_mode.rs | 6 +- src/cli/self_update.rs | 40 +++++----- src/cli/self_update/msg.rs | 6 +- src/cli/self_update/unix.rs | 17 ++-- src/cli/self_update/windows.rs | 85 +++++++++++++++++--- src/config.rs | 5 ++ src/process.rs | 1 - src/test/clitools.rs | 2 + src/toolchain.rs | 23 +++--- tests/suite/cli_inst_interactive.rs | 50 +++++++++++- tests/suite/cli_misc.rs | 51 +++++++----- tests/suite/cli_self_upd.rs | 119 +++++++++++++++++++++++++++- 13 files changed, 324 insertions(+), 83 deletions(-) diff --git a/src/cli/proxy_mode.rs b/src/cli/proxy_mode.rs index 6fae0b3729..5f5f5cca4d 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, &process.cargo_home()?.join("bin"))?; + self_update::cleanup_self_updater(process, &process.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 3a703bc0b9..950ba21fca 100644 --- a/src/cli/rustup_mode.rs +++ b/src/cli/rustup_mode.rs @@ -700,7 +700,7 @@ pub async fn main( .bin("rustup") .complete(); - self_update::cleanup_self_updater(process, &process.cargo_home()?.join("bin"))?; + self_update::cleanup_self_updater(process, &cfg.bin_dir)?; use clap::error::ErrorKind::*; let matches = match Rustup::try_parse_from(process.args_os()) { @@ -2191,6 +2191,10 @@ date = "2025-01-01" "RUSTUP_HOME".to_owned(), rustup_home.path().display().to_string(), ), + ( + "CARGO_HOME".to_owned(), + rustup_home.path().display().to_string(), + ), ( "RUSTUP_OVERRIDE_UNIX_FALLBACK_SETTINGS".to_owned(), rustup_home diff --git a/src/cli/self_update.rs b/src/cli/self_update.rs index 55d06f0bca..fca66df520 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 update`): //! -//! * download rustup-init to a managed path under `$RUSTUP_HOME` +//! * download rustup-init to `self-update` under the Rustup state home //! * run the downloaded binary in replacement mode //! * atomically replace rustup and update its proxy links. On Windows //! this happens after the update command exits. @@ -198,17 +198,17 @@ impl InstallOpts<'_> { return Ok(ExitCode::FAILURE); } - let cargo_home = process.cargo_home()?; + let bin_home = process.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()), + cargo_bin_dir = 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()), + cargo_bin_dir = HomeDisplay::new(&bin_home, home_dir.as_deref()), ) }; md(&mut term, msg); @@ -247,8 +247,8 @@ impl InstallOpts<'_> { quiet: bool, process: &Process, ) -> anyhow::Result<()> { - let cargo_bin = process.cargo_home()?.join("bin"); - install_bins(process, &cargo_bin, force_hard_links(process))?; + let bin_home = process.bin_home()?; + install_bins(process, &bin_home, force_hard_links(process))?; #[cfg(unix)] unix::write_env_files(process)?; @@ -287,7 +287,7 @@ impl InstallOpts<'_> { ChannelToolchain::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())?; @@ -630,10 +630,10 @@ fn rustc_or_cargo_exists_in_path(process: &Process) -> anyhow::Result<()> { let Some(paths) = process.var_os("PATH") else { return Ok(()); }; - let cargo_bin = process.cargo_home()?.join("bin"); + let bin_home = process.bin_home()?; let conflict = env::split_paths(&paths).find(|path| { - path != &cargo_bin + path != &bin_home && (path.join(format!("rustc{EXE_SUFFIX}")).exists() || path.join(format!("cargo{EXE_SUFFIX}")).exists()) }); @@ -699,8 +699,9 @@ 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 cargo_bin_dir = process.bin_home()?; let rustup_home = process.rustup_home()?; + let home_dir = process.home_dir(); if !no_modify_path { // Brittle code warning: some duplication in unix::add_to_path @@ -715,7 +716,7 @@ fn pre_install_msg(no_modify_path: bool, process: &Process) -> anyhow::Result anyhow::Result a } pub(crate) fn install_proxies(process: &Process) -> anyhow::Result<()> { - let bin_path = process.cargo_home()?.join("bin"); + let bin_path = process.bin_home()?; install_proxies_with_opts(&bin_path, force_hard_links(process)) } @@ -1204,11 +1205,11 @@ fn parse_new_rustup_version(version: String) -> String { } 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 bin_home = dl_cfg.process.bin_home()?; + let rustup_path = bin_home.join(format!("rustup{EXE_SUFFIX}")); if !rustup_path.exists() { - return Err(CliError::NotSelfInstalled { p: cargo_home }.into()); + return Err(CliError::NotSelfInstalled { p: bin_home }.into()); } let self_update_lock = SelfUpdateLock::lock(dl_cfg.process)?; @@ -1431,6 +1432,7 @@ mod tests { with_rustup_home(|home| { let mut vars = HashMap::new(); home.apply(&mut vars); + vars.env("CARGO_HOME", &home.rustupdir); let tp = TestProcess::with_vars(vars); let mut cfg = Cfg::from_env(tp.process.current_dir().unwrap(), false, true, &tp.process).unwrap(); diff --git a/src/cli/self_update/msg.rs b/src/cli/self_update/msg.rs index 98292dd463..11806383f1 100644 --- a/src/cli/self_update/msg.rs +++ b/src/cli/self_update/msg.rs @@ -24,7 +24,7 @@ The Cargo home directory is located at: This can be modified with the `CARGO_HOME` environment variable. The `cargo`, `rustc`, `rustup` and other commands will be added to -Cargo's bin directory, located at: +the bin home directory, located at: {cargo_bin_dir} @@ -76,7 +76,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}`). +the bin home directory (`{cargo_bin_dir}`). " }; } @@ -85,7 +85,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 the bin home directory (`{cargo_bin_dir}`) in your `PATH` environment variable. This has not been done automatically. " }; diff --git a/src/cli/self_update/unix.rs b/src/cli/self_update/unix.rs index 39f4d9e2b2..c7fdbbb70c 100644 --- a/src/cli/self_update/unix.rs +++ b/src/cli/self_update/unix.rs @@ -97,7 +97,7 @@ pub(crate) fn add_to_path(process: &Process) -> anyhow::Result<()> { pub(crate) fn write_env_files(process: &Process) -> anyhow::Result<()> { let env_home = process.env_home()?; - let bin_dir = process.cargo_home()?.join("bin"); + let bin_dir = process.bin_home()?; let home_dir = process.home_dir(); utils::ensure_dir_exists("env", &env_home)?; let mut written = vec![]; @@ -132,16 +132,19 @@ pub(super) fn run_update( 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 { let self_update_lock = SelfUpdateLock::lock(process)?; #[cfg(feature = "test")] process.checkpoint(super::CHECKPOINT_SELF_REPLACE_READY); - let result = process.cargo_home().and_then(|cargo_home| { - self_update_lock.install_bins(&cargo_home.join("bin"), super::force_hard_links(process)) - }); + + let result = process + .bin_home() + .map_err(anyhow::Error::from) + .and_then(|bin_home| { + self_update_lock.install_bins(&bin_home, super::force_hard_links(process)) + }); stage::mark_result(result.is_ok(), process); result?; diff --git a/src/cli/self_update/windows.rs b/src/cli/self_update/windows.rs index d1d73a5d5d..5e8de2a428 100644 --- a/src/cli/self_update/windows.rs +++ b/src/cli/self_update/windows.rs @@ -10,7 +10,7 @@ use std::{ fs::OpenOptionsExt, io::{AsRawHandle, FromRawHandle, OwnedHandle, RawHandle}, }, - path::{Path, PathBuf}, + path::PathBuf, process::{Child, Command, Stdio}, ptr, thread, time::Duration, @@ -505,7 +505,7 @@ fn wait_for_parent_legacy() -> 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 new_path = _with_path_rustup_bin(_add_to_path, process)?; _apply_new_path(new_path, process) } @@ -599,18 +599,20 @@ 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_rustup_bin(f: F, 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"); + let path_str = process.bin_home()?; Ok(windows_path.and_then(|old_path| f(old_path, HSTRING::from(path_str.as_path())))) } pub(crate) fn remove_from_path(process: &Process) -> anyhow::Result<()> { - let new_path = _with_path_cargo_home_bin(_remove_from_path, process)?; + let windows_path = get_windows_path_var(process)?; + let path_str = process.cargo_home()?.join("bin"); + let new_path = windows_path + .and_then(|old_path| _remove_from_path(old_path, HSTRING::from(path_str.as_path()))); _apply_new_path(new_path, process) } @@ -671,8 +673,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.bin_home()?.join("rustup.exe"); let mut uninstall_cmd = OsString::from("\""); uninstall_cmd.push(path); uninstall_cmd.push("\" self uninstall"); @@ -706,10 +707,13 @@ pub(super) fn run_update( pub(crate) fn self_replace(process: &Process) -> anyhow::Result { wait_for_parent(process)?; let self_update_lock = SelfUpdateLock::lock(process)?; - let result = process.cargo_home().and_then(|cargo_home| { - self_update_lock.install_bins(&cargo_home.join("bin"), super::force_hard_links(process))?; - update_uninstall_registry_display_version(env!("CARGO_PKG_VERSION"), process) - }); + let result = process + .bin_home() + .map_err(anyhow::Error::from) + .and_then(|bin_home| { + self_update_lock.install_bins(&bin_home, super::force_hard_links(process))?; + update_uninstall_registry_display_version(env!("CARGO_PKG_VERSION"), process) + }); stage::mark_result(result.is_ok(), process); result?; @@ -879,6 +883,61 @@ mod tests { } } + #[test] + fn legacy_mode_uninstall_uses_cargo_bin() { + let cwd = std::env::current_dir().unwrap(); + let cargo_home = cwd.join("cargo home"); + let tp = TestProcess::with_vars(HashMap::from([ + (RUSTUP_REGISTRY_TEST_ID.to_owned(), test_id()), + ( + "CARGO_HOME".to_owned(), + cargo_home.to_str().unwrap().to_owned(), + ), + ("RUSTUP_USE_CATEGORY_HOME".to_owned(), "0".to_owned()), + ])); + add_uninstall_registry_entry(&tp.process).unwrap(); + assert_eq!( + rustup_uninstall_registry_key(&tp.process) + .unwrap() + .get_string("UninstallString") + .unwrap(), + format!( + "\"{}\" self uninstall", + cargo_home.join("bin").join("rustup.exe").display() + ) + ); + } + + #[test] + fn category_mode_uninstall_uses_rustup_bin() { + let cwd = std::env::current_dir().unwrap(); + let cargo_home = cwd.join("cargo home"); + let bin_home = cwd.join("category bin"); + let tp = TestProcess::with_vars(HashMap::from([ + (RUSTUP_REGISTRY_TEST_ID.to_owned(), test_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(), "1".to_owned()), + ])); + add_uninstall_registry_entry(&tp.process).unwrap(); + assert_eq!( + rustup_uninstall_registry_key(&tp.process) + .unwrap() + .get_string("UninstallString") + .unwrap(), + format!( + "\"{}\" self uninstall", + bin_home.join("rustup.exe").display() + ) + ); + } + #[test] fn windows_registry_isolated_per_test_id() { let first_id = test_id(); @@ -1073,7 +1132,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_rustup_bin(|_, _| panic!("called"), &tp.process).unwrap() ); assert_eq!( diff --git a/src/config.rs b/src/config.rs index 9248071f02..44fb3e2797 100644 --- a/src/config.rs +++ b/src/config.rs @@ -321,6 +321,7 @@ pub(crate) struct Cfg<'a> { fallback_settings: Option, pub toolchains_dir: PathBuf, update_hash_dir: PathBuf, + pub bin_dir: PathBuf, pub cache_dir: PathBuf, pub config_dir: PathBuf, pub data_dir: PathBuf, @@ -353,6 +354,7 @@ impl<'a> Cfg<'a> { ) -> anyhow::Result { // Set up the rustup home directory let rustup_dir = process.rustup_home()?; + let bin_dir = process.bin_home()?; let home_dirs = process.home_dirs()?; let cache_dir = home_dirs.cache; let config_dir = home_dirs.config; @@ -415,6 +417,7 @@ impl<'a> Cfg<'a> { fallback_settings, toolchains_dir, update_hash_dir, + bin_dir, cache_dir, config_dir, data_dir, @@ -1198,6 +1201,7 @@ impl Debug for Cfg<'_> { fallback_settings, toolchains_dir, update_hash_dir, + bin_dir, cache_dir, config_dir, data_dir, @@ -1222,6 +1226,7 @@ impl Debug for Cfg<'_> { .field("fallback_settings", fallback_settings) .field("toolchains_dir", toolchains_dir) .field("update_hash_dir", update_hash_dir) + .field("bin_dir", bin_dir) .field("cache_dir", cache_dir) .field("config_dir", config_dir) .field("data_dir", data_dir) diff --git a/src/process.rs b/src/process.rs index 3f4f241dc7..e544f6c861 100644 --- a/src/process.rs +++ b/src/process.rs @@ -114,7 +114,6 @@ impl Process { /// /// Category mode uses the binary directory resolver in [`home`]. /// Legacy mode appends `bin` to the resolved Cargo home. - #[allow(dead_code, reason = "split-home interface is not consumed yet")] pub(crate) fn bin_home(&self) -> io::Result { if self.use_category_home() { return home::bin_home(self); diff --git a/src/test/clitools.rs b/src/test/clitools.rs index e8cda4faa4..cecf4054c3 100644 --- a/src/test/clitools.rs +++ b/src/test/clitools.rs @@ -806,6 +806,8 @@ async fn setup_test_state(test_dist_dir: TempDir) -> (TempDir, Config) { env::remove_var("RUSTUP_CONFIG_HOME"); env::remove_var("RUSTUP_DATA_HOME"); env::remove_var("RUSTUP_STATE_HOME"); + env::remove_var("RUSTUP_BIN_HOME"); + env::remove_var("RUSTUP_USE_CATEGORY_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 ace2f36a72..95a8e98a26 100644 --- a/src/toolchain.rs +++ b/src/toolchain.rs @@ -146,11 +146,18 @@ 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. + let use_category_home = self.cfg.process.use_category_home(); + // TODO: replace `process.cargo_home()` once we don't rely on `home` crate anymore + // currently CARGO_HOME visiblility is checked twice. + if (!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)); @@ -158,7 +165,8 @@ impl<'a> Toolchain<'a> { cmd.env("RUSTUP_CONFIG_HOME", &self.cfg.config_dir); cmd.env("RUSTUP_DATA_HOME", &self.cfg.data_dir); cmd.env("RUSTUP_STATE_HOME", &self.cfg.state_dir); - if !self.cfg.process.use_category_home() { + cmd.env("RUSTUP_BIN_HOME", &self.cfg.bin_dir); + if !use_category_home { cmd.env("RUSTUP_HOME", &self.cfg.rustup_dir); } } @@ -208,14 +216,11 @@ 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 proxy directory to the PATH variable 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")); - } + let mut path_entries = vec![self.cfg.bin_dir.clone()]; // On Windows, we append the "bin" directory to PATH by default. // Windows loads DLLs from PATH and the "bin" directory contains DLLs diff --git a/tests/suite/cli_inst_interactive.rs b/tests/suite/cli_inst_interactive.rs index 1ca8d3792a..d2f8f5c32a 100644 --- a/tests/suite/cli_inst_interactive.rs +++ b/tests/suite/cli_inst_interactive.rs @@ -110,7 +110,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 the bin home directory (%USERPROFILE%/.cargo/bin) in[..] your PATH environment variable. This has not been done automatically. @@ -121,7 +121,7 @@ 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 the bin home 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 @@ -163,7 +163,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). +the bin home directory (%USERPROFILE%/.cargo/bin). Press the Enter key to continue. @@ -174,7 +174,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 ($HOME/.cargo/bin). +the bin home directory ($HOME/.cargo/bin). To configure your current shell, you need to source the corresponding env file under $HOME/.cargo. @@ -192,9 +192,11 @@ Consider running the right command for your shell (note the leading DOT): async fn category_install_displays_shell_setup() { let cx = CliTestContext::new(Scenario::Empty).await; let data_home = cx.config.homedir.join("data"); + let bin_home = cx.config.homedir.join("test-bin-home"); let env = [ ("RUSTUP_USE_CATEGORY_HOME", "1"), ("RUSTUP_DATA_HOME", data_home.to_str().unwrap()), + ("RUSTUP_BIN_HOME", bin_home.to_str().unwrap()), ("PATH", cx.config.exedir.to_str().unwrap()), ("SHELL", "/bin/sh"), ]; @@ -206,6 +208,8 @@ async fn category_install_displays_shell_setup() { .is_ok() .with_stdout(snapbox::str![[r#" ... +the bin home directory ($HOME/test-bin-home). +... corresponding env file under $HOME/data. ... . "[DATA_HOME]/env" # For sh/ash/dash/pdksh @@ -213,6 +217,44 @@ corresponding env file under $HOME/data. "#]]); } +#[tokio::test] +async fn reinstall_ignores_own_bin_in_path_with_category_mode_enabled() { + let cx = CliTestContext::new(Scenario::Empty).await; + let bin_home = cx.config.homedir.join("bin"); + let env = [ + ("RUSTUP_USE_CATEGORY_HOME", "1"), + ("RUSTUP_BIN_HOME", bin_home.to_str().unwrap()), + ("PATH", bin_home.to_str().unwrap()), + ("RUSTUP_INIT_SKIP_PATH_CHECK", ""), + ]; + cx.config + .expect_with_env( + [ + "rustup-init", + "-y", + "--default-toolchain", + "none", + "--no-modify-path", + ], + env, + ) + .await + .is_ok(); + run_input_with_env( + &cx.config, + &[ + "rustup-init", + "--default-toolchain", + "none", + "--no-modify-path", + ], + "\n\n", + &env, + ) + .is_ok() + .without_stderr("It looks like you have an existing installation of Rust"); +} + #[tokio::test] async fn blank_lines_around_stderr_log_output_update() { let cx = CliTestContext::new(Scenario::SimpleV2).await; diff --git a/tests/suite/cli_misc.rs b/tests/suite/cli_misc.rs index 277e794855..f9a350a62a 100644 --- a/tests/suite/cli_misc.rs +++ b/tests/suite/cli_misc.rs @@ -490,10 +490,15 @@ async fn rustup_doesnt_prepend_path_unnecessarily() { #[tokio::test] async fn rustup_doesnt_prepend_path_unnecessarily_with_category_mode_enabled() { let cx = CliTestContext::new(Scenario::SimpleV2).await; + let bin_home = cx.config.current_dir().join("bin"); + let bin_home_env = bin_home.to_str().unwrap(); cx.config .expect_with_env( ["rustup", "default", "nightly"], - [("RUSTUP_USE_CATEGORY_HOME", "1")], + [ + ("RUSTUP_USE_CATEGORY_HOME", "1"), + ("RUSTUP_BIN_HOME", bin_home_env), + ], ) .await .is_ok(); @@ -507,18 +512,20 @@ async fn rustup_doesnt_prepend_path_unnecessarily_with_category_mode_enabled() { snapbox::assert_data_eq!(stderr, data); }; - // For all of these, CARGO_HOME/bin will be auto-prepended. - let cargo_home_bin = cx.config.cargodir.join("bin"); + // For all of these, bin home will be auto-prepended. assert_ok_with_paths( cx.config .expect_with_env( ["cargo", "--echo-env", "PATH"], - [("RUSTUP_USE_CATEGORY_HOME", "1")], + [ + ("RUSTUP_USE_CATEGORY_HOME", "1"), + ("RUSTUP_BIN_HOME", bin_home_env), + ], ) .await - .extend_redactions([("[CARGO_HOME_BIN]", &cargo_home_bin)]), + .extend_redactions([("[BIN_HOME]", &bin_home)]), snapbox::str![[r#" -[CARGO_HOME_BIN] +[BIN_HOME] ... "#]], ); @@ -527,17 +534,21 @@ async fn rustup_doesnt_prepend_path_unnecessarily_with_category_mode_enabled() { cx.config .expect_with_env( ["cargo", "--echo-env", "PATH"], - [("PATH", ""), ("RUSTUP_USE_CATEGORY_HOME", "1")], + [ + ("PATH", ""), + ("RUSTUP_USE_CATEGORY_HOME", "1"), + ("RUSTUP_BIN_HOME", bin_home_env), + ], ) .await - .extend_redactions([("[CARGO_HOME_BIN]", &cargo_home_bin)]), + .extend_redactions([("[BIN_HOME]", &bin_home)]), snapbox::str![[r#" -[CARGO_HOME_BIN] +[BIN_HOME] ... "#]], ); - // Check that CARGO_HOME/bin is prepended to path. + // Check that bin home is prepended to path. assert_ok_with_paths( cx.config .expect_with_env( @@ -545,21 +556,19 @@ async fn rustup_doesnt_prepend_path_unnecessarily_with_category_mode_enabled() { [ ("PATH", &*cx.config.exedir.display().to_string()), ("RUSTUP_USE_CATEGORY_HOME", "1"), + ("RUSTUP_BIN_HOME", bin_home_env), ], ) .await - .extend_redactions([ - ("[CARGO_HOME_BIN]", &cargo_home_bin), - ("[EXEDIR]", &cx.config.exedir), - ]), + .extend_redactions([("[BIN_HOME]", &bin_home), ("[EXEDIR]", &cx.config.exedir)]), snapbox::str![[r#" -[CARGO_HOME_BIN] +[BIN_HOME] [EXEDIR] ... "#]], ); - // But if CARGO_HOME/bin is already on PATH, it will not be prepended again, + // But if bin home is already on PATH, it will not be prepended again, // so exedir will take precedence. assert_ok_with_paths( cx.config @@ -568,22 +577,20 @@ async fn rustup_doesnt_prepend_path_unnecessarily_with_category_mode_enabled() { [ ( "PATH", - std::env::join_paths([&cx.config.exedir, &cargo_home_bin]) + std::env::join_paths([&cx.config.exedir, &bin_home]) .unwrap() .to_str() .unwrap(), ), ("RUSTUP_USE_CATEGORY_HOME", "1"), + ("RUSTUP_BIN_HOME", bin_home_env), ], ) .await - .extend_redactions([ - ("[CARGO_HOME_BIN]", &cargo_home_bin), - ("[EXEDIR]", &cx.config.exedir), - ]), + .extend_redactions([("[BIN_HOME]", &bin_home), ("[EXEDIR]", &cx.config.exedir)]), snapbox::str![[r#" [EXEDIR] -[CARGO_HOME_BIN] +[BIN_HOME] ... "#]], ); diff --git a/tests/suite/cli_self_upd.rs b/tests/suite/cli_self_upd.rs index c6e2c30e30..59c3dcf701 100644 --- a/tests/suite/cli_self_upd.rs +++ b/tests/suite/cli_self_upd.rs @@ -84,7 +84,7 @@ info: default toolchain set to stable fn check(path: &Path) { assert!(path.exists()); } - #[cfg(not(windows))] + #[cfg(unix)] fn check(path: &Path) { fn is_exe(path: &Path) -> bool { use std::os::unix::fs::MetadataExt; @@ -100,6 +100,46 @@ info: default toolchain set to stable } } +#[tokio::test] +async fn install_bins_with_category_mode_enabled() { + let cx = CliTestContext::new(Scenario::SimpleV2).await; + let bin_home = cx.config.current_dir().join("relative/bin"); + let bin_home_env = bin_home.to_str().unwrap(); + + cx.config + .expect_with_env( + ["rustup-init", "-y"], + [ + ("RUSTUP_BIN_HOME", bin_home_env), + ("RUSTUP_USE_CATEGORY_HOME", "1"), + ], + ) + .await + .with_stdout(snapbox::str![[r#" +... + stable-[HOST_TUPLE] installed - 1.1.0 (hash-stable-1.1.0) +... +"#]]) + .with_stderr(snapbox::str![[r#" +... +info: syncing channel updates for stable-[HOST_TUPLE] +info: latest update on 2015-01-02 for version 1.1.0 (hash-stable-1.1.0) +info: downloading 4 components +info: default toolchain set to stable + +"#]]) + .is_ok(); + for tool in TOOLS.iter().chain(DUP_TOOLS.iter()) { + let path = &bin_home.join(format!("{tool}{EXE_SUFFIX}")); + assert!(path.is_file()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!(path.metadata().unwrap().permissions().mode() & 0o777, 0o755); + } + } +} + /// Ensure that proxies are relative symlinks. #[tokio::test] async fn proxies_are_relative_symlinks() { @@ -569,11 +609,11 @@ async fn update_but_not_installed() { cx.config .expect(["rustup", "self", "update"]) .await - .extend_redactions([("[CARGO_DIR]", cx.config.cargodir)]) + .extend_redactions([("[BIN_HOME]", cx.config.cargodir.join("bin"))]) .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 '[BIN_HOME]' "#]]); } @@ -727,6 +767,45 @@ async fn update_updates_rustup_bin() { assert_ne!(before_hash, after_hash); } +#[tokio::test] +async fn update_updates_rustup_bin_with_category_mode_enabled() { + let cx = SelfUpdateTestContext::new(TEST_VERSION).await; + let state_home = cx.config.homedir.join("state"); + let bin_home = cx.config.homedir.join("bin"); + let env = [ + ("RUSTUP_USE_CATEGORY_HOME", "1"), + ("RUSTUP_STATE_HOME", state_home.to_str().unwrap()), + ("RUSTUP_BIN_HOME", bin_home.to_str().unwrap()), + ]; + cx.config + .expect_with_env( + [ + "rustup-init", + "-y", + "--no-modify-path", + "--default-toolchain", + "none", + ], + env, + ) + .await + .is_ok(); + + let bin = bin_home.join(format!("rustup{EXE_SUFFIX}")); + let before_hash = calc_hash(&bin); + let mut cmd = Command::new(&bin); + cx.config.env(&mut cmd); + cmd.envs(env).args(["self", "update"]); + let out = cmd.output().unwrap(); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + wait_for_completed_update(&state_home); + assert_ne!(before_hash, calc_hash(&bin)); +} + #[tokio::test] async fn update_bad_schema() { let cx = SelfUpdateTestContext::new(TEST_VERSION).await; @@ -931,6 +1010,40 @@ async fn updater_is_deleted_after_running_rustc() { assert!(!managed_updater(&cx.config.rustupdir.rustupdir).exists()); } +#[tokio::test] +async fn updater_is_deleted_after_running_rustc_with_category_mode_enabled() { + let cx = SelfUpdateTestContext::new(TEST_VERSION).await; + let state_home = cx.config.current_dir().join("state"); + let bin_home = cx.config.current_dir().join("bin"); + let env = [ + ("RUSTUP_USE_CATEGORY_HOME", "1"), + ("RUSTUP_STATE_HOME", state_home.to_str().unwrap()), + ("RUSTUP_BIN_HOME", bin_home.to_str().unwrap()), + ]; + cx.config + .expect_with_env(["rustup-init", "-y", "--no-modify-path"], env) + .await + .is_ok(); + cx.config + .expect_with_env(["rustup", "default", "nightly"], env) + .await + .is_ok(); + let rustup = bin_home.join(format!("rustup{EXE_SUFFIX}")); + cx.config + .expect_with_env([rustup.to_str().unwrap(), "self", "update"], env) + .await + .is_ok(); + wait_for_completed_update(&state_home); + assert!(managed_updater(&state_home).is_file()); + + let rustc = bin_home.join(format!("rustc{EXE_SUFFIX}")); + cx.config + .expect_with_env([rustc.to_str().unwrap(), "--version"], env) + .await + .is_ok(); + assert!(!managed_updater(&state_home).exists()); +} + #[tokio::test] async fn rustup_still_works_after_update() { let cx = SelfUpdateTestContext::new(TEST_VERSION).await; From ec40e58addaee7565be932cffd46a349df57926e Mon Sep 17 00:00:00 2001 From: Cloud0310 <60375730+Cloud0310@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:21:43 +0800 Subject: [PATCH 13/14] feat(cli): display resolved category homes --- src/cli/rustup_mode.rs | 43 +++++++-- src/cli/self_update.rs | 88 ++++++++++++------- src/cli/self_update/msg.rs | 7 +- src/cli/self_update/shell.rs | 8 +- tests/suite/cli_inst_interactive.rs | 80 ++++++++++++++++- tests/suite/cli_rustup.rs | 74 ++++++++++++++++ .../rustup_show_cmd_help_flag.stdout.term.svg | 2 +- ...how_cmd_home_cmd_help_flag.stdout.term.svg | 2 +- 8 files changed, 254 insertions(+), 50 deletions(-) diff --git a/src/cli/rustup_mode.rs b/src/cli/rustup_mode.rs index 950ba21fca..37d3e7966e 100644 --- a/src/cli/rustup_mode.rs +++ b/src/cli/rustup_mode.rs @@ -385,7 +385,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 @@ -1318,14 +1318,27 @@ async fn show(cfg: &Cfg<'_>, verbose: bool) -> anyhow::Result { cfg.default_host_tuple()? )?; - // Print rustup home directory { let mut t = t.lock(); - writeln!( - t, - "{HEADER}rustup home: {HEADER:#}{}", - cfg.rustup_dir.display() - )?; + if cfg.process.use_category_home() { + writeln!(t, "{HEADER}rustup homes:{HEADER:#}")?; + for (name, home) in [ + ("config", &cfg.config_dir), + ("state", &cfg.state_dir), + ("data", &cfg.data_dir), + ("cache", &cfg.cache_dir), + ("bin", &cfg.bin_dir), + ] { + 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.data_dir.display() + )?; + } writeln!(t)?; } @@ -1473,7 +1486,21 @@ async fn show_active_toolchain(cfg: &Cfg<'_>, verbose: bool) -> anyhow::Result) -> anyhow::Result { - writeln!(cfg.process.stdout().lock(), "{}", cfg.rustup_dir.display())?; + let mut stdout = cfg.process.stdout().lock(); + if !cfg.process.use_category_home() { + // In legacy mode, all four category homes equal the resolved RUSTUP_HOME. + writeln!(stdout, "{}", cfg.data_dir.display())?; + return Ok(ExitCode::SUCCESS); + } + + for (name, home) in [ + ("config", &cfg.config_dir), + ("state", &cfg.state_dir), + ("data", &cfg.data_dir), + ("cache", &cfg.cache_dir), + ] { + writeln!(stdout, "{name}: {}", home.display())?; + } Ok(ExitCode::SUCCESS) } diff --git a/src/cli/self_update.rs b/src/cli/self_update.rs index fca66df520..e767a5738e 100644 --- a/src/cli/self_update.rs +++ b/src/cli/self_update.rs @@ -700,43 +700,71 @@ 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 = process.bin_home()?; - let rustup_home = process.rustup_home()?; + let home_dirs = process.home_dirs()?; let home_dir = process.home_dir(); + let rustup_home_message = if process.use_category_home() { + format!( + "Rustup will use these directories: + + config: {} + state: {} + data: {} + cache: {} + +They can be modified individually with +`RUSTUP_CONFIG_HOME`, `RUSTUP_STATE_HOME`, `RUSTUP_DATA_HOME`, and +`RUSTUP_CACHE_HOME`.", + HomeDisplay::new(&home_dirs.config, home_dir.as_deref()), + HomeDisplay::new(&home_dirs.state, home_dir.as_deref()), + HomeDisplay::new(&home_dirs.data, home_dir.as_deref()), + HomeDisplay::new(&home_dirs.cache, home_dir.as_deref()), + ) + } else { + format!( + "Rustup metadata and toolchains will be installed into the Rustup +home directory, located at: - if !no_modify_path { - // Brittle code warning: some duplication in unix::add_to_path - #[cfg(not(windows))] - { - let rcfiles = shell::get_available_shells(process) - .flat_map(|sh| sh.rcs(process).into_iter()) - .map(|rc| format!(" {}", rc.display())) - .collect::>(); - let plural = if rcfiles.len() > 1 { "s" } else { "" }; - let rcfiles = rcfiles.join("\n"); - Ok(format!( - pre_install_msg_unix!(), - cargo_home = cargo_home.display(), - cargo_bin_dir = HomeDisplay::new(&cargo_bin_dir, home_dir.as_deref()), - plural = plural, - rcfiles = rcfiles, - rustup_home = rustup_home.display(), - )) - } - #[cfg(windows)] - Ok(format!( - pre_install_msg_win!(), - cargo_home = cargo_home.display(), + {} + +This can be modified with the `RUSTUP_HOME` environment variable.", + HomeDisplay::new(&home_dirs.data, home_dir.as_deref()), + ) + }; + + if no_modify_path { + return Ok(format!( + pre_install_msg_no_modify_path!(), + cargo_home = HomeDisplay::new(&cargo_home, home_dir.as_deref()), cargo_bin_dir = HomeDisplay::new(&cargo_bin_dir, home_dir.as_deref()), - rustup_home = rustup_home.display(), - )) - } else { + rustup_home_message = rustup_home_message, + )); + } + + // Brittle code warning: some duplication in unix::add_to_path + #[cfg(not(windows))] + { + let rcfiles = shell::get_available_shells(process) + .flat_map(|sh| sh.rcs(process).into_iter()) + .map(|rc| format!(" {}", HomeDisplay::new(&rc, home_dir.as_deref()))) + .collect::>(); + let plural = if rcfiles.len() > 1 { "s" } else { "" }; + let rcfiles = rcfiles.join("\n"); Ok(format!( - pre_install_msg_no_modify_path!(), - cargo_home = cargo_home.display(), + pre_install_msg_unix!(), + cargo_home = HomeDisplay::new(&cargo_home, home_dir.as_deref()), cargo_bin_dir = HomeDisplay::new(&cargo_bin_dir, home_dir.as_deref()), - rustup_home = rustup_home.display(), + plural = plural, + rcfiles = rcfiles, + rustup_home_message = rustup_home_message, )) } + #[cfg(windows)] + Ok(format!( + pre_install_msg_win!(), + cargo_home = HomeDisplay::new(&cargo_home, home_dir.as_deref()), + cargo_bin_dir = HomeDisplay::new(&cargo_bin_dir, home_dir.as_deref()), + rustup_home_message = rustup_home_message, + )) } #[cfg(unix)] diff --git a/src/cli/self_update/msg.rs b/src/cli/self_update/msg.rs index 11806383f1..febb2c12c6 100644 --- a/src/cli/self_update/msg.rs +++ b/src/cli/self_update/msg.rs @@ -10,12 +10,7 @@ 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. +{rustup_home_message} The Cargo home directory is located at: diff --git a/src/cli/self_update/shell.rs b/src/cli/self_update/shell.rs index 92b4dc984a..3430fe5948 100644 --- a/src/cli/self_update/shell.rs +++ b/src/cli/self_update/shell.rs @@ -27,7 +27,7 @@ use std::path::{Path, PathBuf}; use anyhow::bail; -use super::utils; +use super::{HomeDisplay, utils}; use crate::process::Process; #[derive(Debug, PartialEq)] @@ -66,7 +66,11 @@ pub(crate) fn build_source_env_lines( ) -> 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 mut env_display = HomeDisplay::new(env_dir, home_dir); + if let Some(p) = env_display.home_prefix.as_mut() { + *p = shell.home_var(); + } + let Ok(src) = shell.source_string(Path::new(&env_display.to_string()), None) else { continue; }; if let Some(names) = groups diff --git a/tests/suite/cli_inst_interactive.rs b/tests/suite/cli_inst_interactive.rs index d2f8f5c32a..45b6b71f62 100644 --- a/tests/suite/cli_inst_interactive.rs +++ b/tests/suite/cli_inst_interactive.rs @@ -204,7 +204,6 @@ async fn category_install_displays_shell_setup() { cx.config .expect_with_env(["rustup-init", "-y", "--default-toolchain", "none"], env) .await - .extend_redactions([("[DATA_HOME]", data_home.to_str().unwrap().to_owned())]) .is_ok() .with_stdout(snapbox::str![[r#" ... @@ -212,7 +211,7 @@ the bin home directory ($HOME/test-bin-home). ... corresponding env file under $HOME/data. ... - . "[DATA_HOME]/env" # For sh/ash/dash/pdksh + . "$HOME/data/env" # For sh/ash/dash/pdksh ... "#]]); } @@ -435,6 +434,83 @@ async fn install_initializes_homes_with_category_mode_enabled() { assert!(cache_home.is_dir()); } +#[tokio::test] +async fn install_displays_homes_with_category_mode_enabled() { + let cx = CliTestContext::new(Scenario::SimpleV2).await; + let config_home = cx.config.homedir.join("config"); + let state_home = cx.config.homedir.join("state"); + let data_home = cx.config.homedir.join("data"); + let cache_home = cx.config.homedir.join("cache"); + let home_var = if cfg!(windows) { + "%USERPROFILE%" + } else { + "$HOME" + }; + + run_input_with_env( + &cx.config, + &["rustup-init", "--no-modify-path"], + "3\n", + &[ + ("RUSTUP_USE_CATEGORY_HOME", "1"), + ("RUSTUP_CONFIG_HOME", config_home.to_str().unwrap()), + ("RUSTUP_STATE_HOME", state_home.to_str().unwrap()), + ("RUSTUP_DATA_HOME", data_home.to_str().unwrap()), + ("RUSTUP_CACHE_HOME", cache_home.to_str().unwrap()), + ], + ) + .extend_redactions([("[HOME_VAR]", home_var)]) + .with_stdout(snapbox::str![[r#" +... +Rustup will use these directories: + + config: [HOME_VAR]/config + state: [HOME_VAR]/state + data: [HOME_VAR]/data + cache: [HOME_VAR]/cache + +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_homes_when_paths_match_legacy() { + let cx = CliTestContext::new(Scenario::Empty).await; + let home = cx.config.rustupdir.to_string(); + run_input_with_env( + &cx.config, + &["rustup-init", "--no-modify-path"], + "3\n", + &[ + ("RUSTUP_USE_CATEGORY_HOME", "1"), + ("RUSTUP_CONFIG_HOME", &home), + ("RUSTUP_STATE_HOME", &home), + ("RUSTUP_DATA_HOME", &home), + ("RUSTUP_CACHE_HOME", &home), + ], + ) + .extend_redactions([("[RUSTUP_DIR]", &home)]) + .with_stdout(snapbox::str![[r#" +... +Rustup will use these directories: + + config: [RUSTUP_DIR] + state: [RUSTUP_DIR] + data: [RUSTUP_DIR] + cache: [RUSTUP_DIR] + +They can be modified individually with +RUSTUP_CONFIG_HOME, RUSTUP_STATE_HOME, RUSTUP_DATA_HOME, and +RUSTUP_CACHE_HOME. +... +"#]]) + .is_ok(); +} + #[tokio::test] async fn with_no_toolchain_doesnt_hang() { let cx = CliTestContext::new(Scenario::SimpleV2).await; diff --git a/tests/suite/cli_rustup.rs b/tests/suite/cli_rustup.rs index badfb3a7fe..87cf7633d5 100644 --- a/tests/suite/cli_rustup.rs +++ b/tests/suite/cli_rustup.rs @@ -1025,6 +1025,80 @@ error: infinite recursion detected .is_err(); } +#[tokio::test] +async fn show_category_homes() { + let cx = CliTestContext::new(Scenario::None).await; + let config_home = cx.config.homedir.join("config home"); + let state_home = cx.config.homedir.join("state home"); + let data_home = cx.config.homedir.join("data home"); + let cache_home = cx.config.homedir.join("cache home"); + let bin_home = cx.config.homedir.join("bin home"); + let env = [ + ("RUSTUP_USE_CATEGORY_HOME", "1"), + ("RUSTUP_CONFIG_HOME", config_home.to_str().unwrap()), + ("RUSTUP_STATE_HOME", state_home.to_str().unwrap()), + ("RUSTUP_DATA_HOME", data_home.to_str().unwrap()), + ("RUSTUP_CACHE_HOME", cache_home.to_str().unwrap()), + ("RUSTUP_BIN_HOME", bin_home.to_str().unwrap()), + ]; + cx.config + .expect_with_env(["rustup", "show", "home"], env) + .await + .extend_redactions([("[HOME]", &cx.config.homedir)]) + .with_stdout(snapbox::str![[r#" +config: [HOME]/config home +state: [HOME]/state home +data: [HOME]/data home +cache: [HOME]/cache home + +"#]]) + .is_ok(); + + cx.config + .expect_with_env(["rustup", "show"], env) + .await + .extend_redactions([("[HOME]", &cx.config.homedir)]) + .with_stdout(snapbox::str![[r#" +Default host: [HOST_TUPLE] +rustup homes: + config: [HOME]/config home + state: [HOME]/state home + data: [HOME]/data home + cache: [HOME]/cache home + bin: [HOME]/bin home + +installed toolchains +-------------------- + +active toolchain +---------------- +no active toolchain + +"#]]) + .is_ok(); +} + +#[cfg(unix)] +#[tokio::test] +async fn show_category_platform_defaults() { + let cx = CliTestContext::new(Scenario::None).await; + cx.config + .expect_with_env( + ["rustup", "show", "home"], + [("RUSTUP_USE_CATEGORY_HOME", "1"), ("RUSTUP_HOME", "")], + ) + .await + .extend_redactions([("[HOME]", &cx.config.homedir)]) + .with_stdout(snapbox::str![[r#" +config: [HOME]/.config/rustup +state: [HOME]/.local/state/rustup +data: [HOME]/.local/share/rustup +cache: [HOME]/.cache/rustup + +"#]]) + .is_ok(); +} + #[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 7e327612c812a23c2049b3bc377337b36eca9286 Mon Sep 17 00:00:00 2001 From: Cloud0310 <60375730+Cloud0310@users.noreply.github.com> Date: Sat, 26 Sep 2026 03:19:43 +0800 Subject: [PATCH 14/14] feat(uninstall): clean legacy and category installations --- src/cli/self_update.rs | 135 ++++++++++++++++++++------------- src/cli/self_update/msg.rs | 19 +++++ src/cli/self_update/shell.rs | 2 +- src/cli/self_update/unix.rs | 9 ++- src/cli/self_update/windows.rs | 17 +++-- tests/suite/cli_paths.rs | 72 ++++++++++++++++++ tests/suite/cli_self_upd.rs | 2 +- 7 files changed, 189 insertions(+), 67 deletions(-) diff --git a/src/cli/self_update.rs b/src/cli/self_update.rs index e767a5738e..693c7b2db7 100644 --- a/src/cli/self_update.rs +++ b/src/cli/self_update.rs @@ -20,7 +20,7 @@ //! * atomically replace rustup and update its proxy links. On Windows //! this happens after the update command exits. //! -//! During uninstall (`rustup self uninstall`): +//! During legacy-mode uninstall (`rustup self uninstall`): //! //! * Delete `$RUSTUP_HOME`. //! * Delete all entries in `$CARGO_HOME` except `bin`. @@ -28,6 +28,9 @@ //! * Delete `$CARGO_HOME/bin` if it is empty after uninstall. //! * Delete `$CARGO_HOME` if it is empty after uninstall. //! +//! Category-mode uninstall removes the current toolchains, env files and binaries, +//! preserving other files in the category homes without separately cleaning legacy homes. +//! //! Deleting the running binary during uninstall is tricky //! and racy on Windows. @@ -955,7 +958,7 @@ fn check_proxy_sanity( Ok(()) } -/// Uninstall process: +/// Legacy uninstall process: /// 1. Remove all installed toolchains. /// 2. Remove rustup home. /// 3. Remove all entries in `$CARGO_HOME` except `bin`. @@ -963,6 +966,9 @@ fn check_proxy_sanity( /// 5. Try to remove $CARGO_HOME/bin directory if it's empty. /// 6. Upon successfully removing $CARGO_HOME/bin, clean up $PATH. /// 7. Try to remove $CARGO_HOME directory if it's empty. +/// +/// Category mode removes the current toolchains, env files and binaries, preserving +/// other files in the category homes without separately cleaning legacy homes. pub(crate) fn uninstall( no_prompt: bool, no_modify_path: bool, @@ -975,18 +981,31 @@ pub(crate) fn uninstall( } let process = cfg.process; - let cargo_home = process.cargo_home()?; + let bin_home = process.bin_home()?; - if !cargo_home.join(format!("bin/rustup{EXE_SUFFIX}")).exists() { - return Err(CliError::NotSelfInstalled { p: cargo_home }.into()); + if !bin_home.join(format!("rustup{EXE_SUFFIX}")).exists() { + return Err(CliError::NotSelfInstalled { p: bin_home }.into()); } if !no_prompt { writeln!(process.stdout().lock())?; - let msg = if no_modify_path { + let msg = if process.use_category_home() { + let home_dir = process.home_dir(); + let path_message = if no_modify_path { + "Your `PATH` environment variable and shell profiles will not be modified." + } else { + "Rustup shell setup and PATH entries will be cleaned up where applicable." + }; + format!( + pre_uninstall_category_msg!(), + toolchains_dir = HomeDisplay::new(&cfg.toolchains_dir, home_dir.as_deref()), + env_home = HomeDisplay::new(&cfg.data_dir, home_dir.as_deref()), + bin_home = HomeDisplay::new(&bin_home, home_dir.as_deref()), + path_message = path_message, + ) + } else if no_modify_path { pre_uninstall_msg_no_modify_path!().to_owned() } else { - 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) }; @@ -1002,17 +1021,28 @@ pub(crate) fn uninstall( Toolchain::ensure_removed(cfg, toolchain.into())?; } - info!("removing rustup home"); - - // Delete RUSTUP_HOME - let rustup_dir = process.rustup_home()?; - if rustup_dir.exists() { - utils::remove_dir("rustup_home", &rustup_dir)?; + if !process.use_category_home() { + info!("removing rustup home"); + let rustup_dir = process.rustup_home()?; + if rustup_dir.exists() { + utils::remove_dir("rustup_home", &rustup_dir)?; + } } // Delete rustup. #[cfg(unix)] - clean_cargo_home(no_modify_path, process, &cargo_home)?; + if process.use_category_home() { + let env_home = process.env_home()?; + for sh in shell::get_available_shells(process) { + let env_file = env_home.join(sh.env_script().name); + if env_file.is_file() { + utils::remove_file("env", &env_file)?; + } + } + clean_bin_home(no_modify_path, process, &bin_home)?; + } else { + clean_cargo_home(no_modify_path, process, &process.cargo_home()?)?; + } // NOTE: On windows, this is tricky because this is *probably* // the running executable and on Windows can't be unlinked until // the process exits. @@ -1025,6 +1055,42 @@ pub(crate) fn uninstall( Ok(ExitCode::SUCCESS) } +/// Removes Rustup's proxies and binary, then removes the bin directory if empty. +fn clean_bin_home(no_modify_path: bool, process: &Process, bin_home: &Path) -> anyhow::Result<()> { + info!("removing rustup tool links and binary"); + + let rustup_path = bin_home.join(format!("rustup{EXE_SUFFIX}")); + for tool in TOOLS.iter().chain(DUP_TOOLS.iter()) { + let proxy_path = bin_home.join(format!("{tool}{EXE_SUFFIX}")); + 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)?; + + #[cfg(windows)] + remove_uninstall_registry_entry(process)?; + + let bin_home_display = bin_home.display(); + info!("removing empty bin directory `{bin_home_display}`"); + + match fs::remove_dir(bin_home) { + Err(e) if e.kind() == io::ErrorKind::DirectoryNotEmpty => { + warn!("keeping non-empty bin directory `{bin_home_display}`"); + } + Err(e) => { + return Err(e) + .with_context(|| format!("failed to remove bin directory `{bin_home_display}`")); + } + Ok(()) if !no_modify_path => { + info!("removing bin directory `{bin_home_display}` from $PATH"); + remove_from_path(process)?; + } + Ok(()) => {} + } + Ok(()) +} + /// 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. @@ -1034,8 +1100,6 @@ fn clean_cargo_home( process: &Process, cargo_home: &Path, ) -> anyhow::Result<()> { - let cargo_bin = cargo_home.join("bin"); - info!("removing cargo home"); // Delete everything in CARGO_HOME except the bin directory first. @@ -1057,44 +1121,7 @@ fn clean_cargo_home( } } - 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)?; - } - } - - 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(()) => {} - } + clean_bin_home(no_modify_path, process, &cargo_home.join("bin"))?; let cargo_home_display = cargo_home.display(); info!("removing empty cargo home directory `{cargo_home_display}`"); diff --git a/src/cli/self_update/msg.rs b/src/cli/self_update/msg.rs index febb2c12c6..58c40156c6 100644 --- a/src/cli/self_update/msg.rs +++ b/src/cli/self_update/msg.rs @@ -112,6 +112,25 @@ This will uninstall all Rust toolchains and data, and remove }; } +macro_rules! pre_uninstall_category_msg { + () => { + r"# Thanks for hacking in Rust! + +This will uninstall the toolchains managed by this installation and remove +Rustup's shell environment files and binaries: + + toolchains: {toolchains_dir} + env files: {env_home} + binaries: {bin_home} + +Other files in these directories and Cargo home data will be kept. + +{path_message} + +" + }; +} + macro_rules! pre_uninstall_msg_no_modify_path { () => { r"# Thanks for hacking in Rust! diff --git a/src/cli/self_update/shell.rs b/src/cli/self_update/shell.rs index 3430fe5948..3402893592 100644 --- a/src/cli/self_update/shell.rs +++ b/src/cli/self_update/shell.rs @@ -33,7 +33,7 @@ use crate::process::Process; #[derive(Debug, PartialEq)] pub(crate) struct ShellScript { content: &'static str, - name: &'static str, + pub(super) name: &'static str, } // TODO: Update into a bytestring. diff --git a/src/cli/self_update/unix.rs b/src/cli/self_update/unix.rs index c7fdbbb70c..0f51d99aed 100644 --- a/src/cli/self_update/unix.rs +++ b/src/cli/self_update/unix.rs @@ -51,15 +51,18 @@ pub(crate) fn anti_sudo_check( } pub(crate) fn remove_from_path(process: &Process) -> anyhow::Result<()> { - let cargo_home = process.cargo_home()?; + let env_home = process.env_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(&env_home, home_dir.as_deref())?; // Check more files for cleanup than normally are updated. remove_source_command(&source_cmd, &sh.rc_candidates(process))?; } - remove_legacy_paths(process, &cargo_home, home_dir.as_deref()) + if !process.use_category_home() { + remove_legacy_paths(process, &env_home, home_dir.as_deref())?; + } + Ok(()) } 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 5e8de2a428..65549c0ed7 100644 --- a/src/cli/self_update/windows.rs +++ b/src/cli/self_update/windows.rs @@ -381,13 +381,17 @@ fn has_windows_sdk_libs(process: &Process) -> bool { false } -/// Run by rustup-gc-$num.exe to delete CARGO_HOME +/// Run by rustup-gc-$num.exe to finish deleting the installation. #[tracing::instrument(level = "trace")] pub fn complete_windows_uninstall(process: &Process) -> anyhow::Result { let uninstall = wait_for_parent(process).and_then(|()| { let no_modify_path = process.var_os(GC_MODIFY_PATH).as_deref() != Some(OsStr::new("1")); - // Now that the parent has exited there are hopefully no more files open in CARGO_HOME. + // Now that the parent has exited, its installed binary can be removed. + if process.use_category_home() { + return super::clean_bin_home(no_modify_path, process, &process.bin_home()?); + } + let cargo_home = process.cargo_home()?; super::clean_cargo_home(no_modify_path, process, &cargo_home) }); @@ -609,10 +613,7 @@ where } pub(crate) fn remove_from_path(process: &Process) -> anyhow::Result<()> { - let windows_path = get_windows_path_var(process)?; - let path_str = process.cargo_home()?.join("bin"); - let new_path = windows_path - .and_then(|old_path| _remove_from_path(old_path, HSTRING::from(path_str.as_path()))); + let new_path = _with_path_rustup_bin(_remove_from_path, process)?; _apply_new_path(new_path, process) } @@ -734,7 +735,7 @@ pub(crate) fn self_replace(process: &Process) -> anyhow::Result // Pass this handle as stdin so the standard library manages inheritance. // GC does not read stdin; it uses it only to carry the deletion handle. // - Run the gc exe, which waits for the original rustup.exe -// process to close, then deletes CARGO_HOME. This process +// process to close, then cleans up the installation. This process // has inherited a FILE_FLAG_DELETE_ON_CLOSE handle to itself. // - Finally, spawn yet another system binary inheriting stdin, // so *it* inherits the FILE_FLAG_DELETE_ON_CLOSE handle to @@ -800,7 +801,7 @@ pub(crate) fn spawn_uninstall_gc(no_modify_path: bool) -> anyhow::Result<()> { } // The rustup-gc executable cannot accept normal function call here, -// so we use env var here, notifying it if we need to remove $CARGO_HOME/bin from $PATH +// so we use env var here, notifying it if we need to remove the bin home from $PATH const GC_MODIFY_PATH: &str = "RUSTUP_GC_MODIFY_PATH"; // Decimal value of the process handle inherited by GC or the self-replacer. diff --git a/tests/suite/cli_paths.rs b/tests/suite/cli_paths.rs index 239f61ec87..20067902fd 100644 --- a/tests/suite/cli_paths.rs +++ b/tests/suite/cli_paths.rs @@ -85,6 +85,39 @@ export PATH="$HOME/apple/bin" assert!(data_home.join("env").is_file()); } + #[tokio::test] + async fn category_uninstall_preserves_legacy_installation() { + let cx = CliTestContext::new(Scenario::Empty).await; + cx.config.expect(INIT_NONE).await.is_ok(); + let legacy_env = fs::read_to_string(cx.config.cargodir.join("env")).unwrap(); + let profile = cx.config.homedir.join(".profile"); + let legacy_profile = fs::read_to_string(&profile).unwrap(); + let data_home = cx.config.homedir.join("data"); + let bin_home = cx.config.homedir.join("bin"); + let env = [ + ("RUSTUP_USE_CATEGORY_HOME", "1"), + ("RUSTUP_DATA_HOME", data_home.to_str().unwrap()), + ("RUSTUP_BIN_HOME", bin_home.to_str().unwrap()), + ]; + cx.config.expect_with_env(INIT_NONE, env).await.is_ok(); + let settings_file = cx.config.rustupdir.rustupdir.join("settings.toml"); + let settings = fs::read_to_string(&settings_file).unwrap(); + cx.config + .expect_with_env(["rustup", "self", "uninstall", "-y"], env) + .await + .is_ok(); + + assert!(!data_home.join("env").exists()); + assert!(!bin_home.exists()); + assert_eq!(fs::read_to_string(&profile).unwrap(), legacy_profile); + assert_eq!( + fs::read_to_string(cx.config.cargodir.join("env")).unwrap(), + legacy_env + ); + assert!(cx.config.cargodir.join("bin/rustup").is_file()); + assert_eq!(fs::read_to_string(settings_file).unwrap(), settings); + } + #[tokio::test] async fn install_updates_bash_rcs() { let cx = CliTestContext::new(Scenario::Empty).await; @@ -568,6 +601,45 @@ mod windows { ); } + #[tokio::test] + async fn category_install_uninstall_affect_path() { + let cx = CliTestContext::new(Scenario::Empty).await; + let test_id = &cx.config.test_registry_id; + let bin_home = cx.config.homedir.join("bin"); + let bin_home_os_str = bin_home.as_os_str(); + let env = [ + ("RUSTUP_USE_CATEGORY_HOME", "1"), + ("RUSTUP_BIN_HOME", bin_home.to_str().unwrap()), + ]; + let read_path = |test_id: &str| -> Option<_> { + retry( + Fibonacci::from_millis(1).map(jitter).take(21), + || match get_path(test_id).unwrap() { + Some(v) => OperationResult::Ok(HSTRING::try_from(v).unwrap().to_os_string()), + None => OperationResult::Retry(()), + }, + ) + .ok() + }; + + cx.config.expect_with_env(INIT_NONE, env).await.is_ok(); + let after_install = read_path(test_id).unwrap_or_default(); + assert!( + os_str_contains(&after_install, bin_home_os_str), + "`{bin_home_os_str:?}` not in `{after_install:?}`", + ); + + cx.config + .expect_with_env(["rustup", "self", "uninstall", "-y"], env) + .await + .is_ok(); + let after_uninstall = read_path(test_id).unwrap_or_default(); + assert!( + !os_str_contains(&after_uninstall, bin_home_os_str), + "`{bin_home_os_str:?}` in `{after_uninstall:?}`", + ); + } + #[tokio::test] async fn uninstall_keeps_path_when_cargo_bin_is_non_empty() { let cx = CliTestContext::new(Scenario::Empty).await; diff --git a/tests/suite/cli_self_upd.rs b/tests/suite/cli_self_upd.rs index 59c3dcf701..d6d1e3688e 100644 --- a/tests/suite/cli_self_upd.rs +++ b/tests/suite/cli_self_upd.rs @@ -364,7 +364,7 @@ async fn complete_uninstall_keeps_non_empty_cargo_bin() { .await .with_stderr(snapbox::str![[r#" ... -warn: keeping non-empty cargo bin directory `[..]` +warn: keeping non-empty bin directory `[..]` ... "#]]) .is_ok();