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