From a86586f40bdd32ae959f08a3365db9b051dd90c1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Dec 2025 14:13:49 +0000 Subject: [PATCH 01/14] Initial plan From 542c8660337e5f79f60e42e8e9be6e44bf150593 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Dec 2025 14:28:04 +0000 Subject: [PATCH 02/14] Add WindowsPathHelper and new commands for PATH management - Created WindowsPathHelper utility class for Windows-specific PATH operations - Added ElevatedAdminPathCommand for 'dotnetup elevatedadminpath removedotnet/adddotnet' - Added SetInstallRootCommand for 'dotnetup setinstallroot user' - Registered new commands in Parser.cs Co-authored-by: dsplaisted <145043+dsplaisted@users.noreply.github.com> --- .../ElevatedAdminPathCommand.cs | 114 ++++++++ .../ElevatedAdminPathCommandParser.cs | 34 +++ .../SetInstallRoot/SetInstallRootCommand.cs | 131 +++++++++ .../SetInstallRootCommandParser.cs | 34 +++ src/Installer/dotnetup/Parser.cs | 4 + src/Installer/dotnetup/WindowsPathHelper.cs | 263 ++++++++++++++++++ 6 files changed, 580 insertions(+) create mode 100644 src/Installer/dotnetup/Commands/ElevatedAdminPath/ElevatedAdminPathCommand.cs create mode 100644 src/Installer/dotnetup/Commands/ElevatedAdminPath/ElevatedAdminPathCommandParser.cs create mode 100644 src/Installer/dotnetup/Commands/SetInstallRoot/SetInstallRootCommand.cs create mode 100644 src/Installer/dotnetup/Commands/SetInstallRoot/SetInstallRootCommandParser.cs create mode 100644 src/Installer/dotnetup/WindowsPathHelper.cs diff --git a/src/Installer/dotnetup/Commands/ElevatedAdminPath/ElevatedAdminPathCommand.cs b/src/Installer/dotnetup/Commands/ElevatedAdminPath/ElevatedAdminPathCommand.cs new file mode 100644 index 000000000000..8696dd052717 --- /dev/null +++ b/src/Installer/dotnetup/Commands/ElevatedAdminPath/ElevatedAdminPathCommand.cs @@ -0,0 +1,114 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.ElevatedAdminPath; + +internal class ElevatedAdminPathCommand : CommandBase +{ + private readonly string _operation; + + public ElevatedAdminPathCommand(ParseResult result) : base(result) + { + _operation = result.GetValue(ElevatedAdminPathCommandParser.OperationArgument)!; + } + + public override int Execute() + { + // This command only works on Windows + if (!OperatingSystem.IsWindows()) + { + Console.Error.WriteLine("Error: The elevatedadminpath command is only supported on Windows."); + return 1; + } + + // Check if running with elevated privileges + if (!WindowsPathHelper.IsElevated()) + { + Console.Error.WriteLine("Error: This operation requires administrator privileges. Please run from an elevated command prompt."); + return 1; + } + + return _operation.ToLowerInvariant() switch + { + "removedotnet" => RemoveDotnet(), + "adddotnet" => AddDotnet(), + _ => throw new InvalidOperationException($"Unknown operation: {_operation}") + }; + } + + [SupportedOSPlatform("windows")] + private int RemoveDotnet() + { + try + { + Console.WriteLine("Reading current admin PATH from registry..."); + string oldPath = WindowsPathHelper.ReadAdminPath(); + + if (!WindowsPathHelper.AdminPathContainsProgramFilesDotnet()) + { + Console.WriteLine("Program Files dotnet path is not present in admin PATH. No changes needed."); + return 0; + } + + Console.WriteLine("Removing Program Files dotnet path from admin PATH..."); + string newPath = WindowsPathHelper.RemoveProgramFilesDotnetFromPath(oldPath); + + Console.WriteLine("Writing updated admin PATH to registry..."); + WindowsPathHelper.WriteAdminPath(newPath); + + // Log the changes + WindowsPathHelper.LogPathChange("Remove dotnet from admin PATH", oldPath, newPath); + + // Broadcast environment change + WindowsPathHelper.BroadcastEnvironmentChange(); + + Console.WriteLine("Successfully removed Program Files dotnet path from admin PATH."); + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error: Failed to remove dotnet from admin PATH: {ex.Message}"); + return 1; + } + } + + [SupportedOSPlatform("windows")] + private int AddDotnet() + { + try + { + Console.WriteLine("Reading current admin PATH from registry..."); + string oldPath = WindowsPathHelper.ReadAdminPath(); + + if (WindowsPathHelper.AdminPathContainsProgramFilesDotnet()) + { + Console.WriteLine("Program Files dotnet path is already present in admin PATH. No changes needed."); + return 0; + } + + Console.WriteLine("Adding Program Files dotnet path to admin PATH..."); + string newPath = WindowsPathHelper.AddProgramFilesDotnetToPath(oldPath); + + Console.WriteLine("Writing updated admin PATH to registry..."); + WindowsPathHelper.WriteAdminPath(newPath); + + // Log the changes + WindowsPathHelper.LogPathChange("Add dotnet to admin PATH", oldPath, newPath); + + // Broadcast environment change + WindowsPathHelper.BroadcastEnvironmentChange(); + + Console.WriteLine("Successfully added Program Files dotnet path to admin PATH."); + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error: Failed to add dotnet to admin PATH: {ex.Message}"); + return 1; + } + } +} diff --git a/src/Installer/dotnetup/Commands/ElevatedAdminPath/ElevatedAdminPathCommandParser.cs b/src/Installer/dotnetup/Commands/ElevatedAdminPath/ElevatedAdminPathCommandParser.cs new file mode 100644 index 000000000000..f7d48e7a7016 --- /dev/null +++ b/src/Installer/dotnetup/Commands/ElevatedAdminPath/ElevatedAdminPathCommandParser.cs @@ -0,0 +1,34 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; + +namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.ElevatedAdminPath; + +internal static class ElevatedAdminPathCommandParser +{ + public static readonly Argument OperationArgument = new("operation") + { + HelpName = "OPERATION", + Description = "The operation to perform: 'removedotnet' or 'adddotnet'", + Arity = ArgumentArity.ExactlyOne, + }; + + private static readonly Command ElevatedAdminPathCommand = ConstructCommand(); + + public static Command GetCommand() + { + return ElevatedAdminPathCommand; + } + + private static Command ConstructCommand() + { + Command command = new("elevatedadminpath", "Modifies the machine-wide admin PATH (requires elevated privileges)"); + + command.Arguments.Add(OperationArgument); + + command.SetAction(parseResult => new ElevatedAdminPathCommand(parseResult).Execute()); + + return command; + } +} diff --git a/src/Installer/dotnetup/Commands/SetInstallRoot/SetInstallRootCommand.cs b/src/Installer/dotnetup/Commands/SetInstallRoot/SetInstallRootCommand.cs new file mode 100644 index 000000000000..1a3283756347 --- /dev/null +++ b/src/Installer/dotnetup/Commands/SetInstallRoot/SetInstallRootCommand.cs @@ -0,0 +1,131 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.SetInstallRoot; + +internal class SetInstallRootCommand : CommandBase +{ + private readonly string _installType; + private readonly IDotnetInstallManager _dotnetInstaller; + + public SetInstallRootCommand(ParseResult result, IDotnetInstallManager? dotnetInstaller = null) : base(result) + { + _installType = result.GetValue(SetInstallRootCommandParser.InstallTypeArgument)!; + _dotnetInstaller = dotnetInstaller ?? new DotnetInstallManager(); + } + + public override int Execute() + { + return _installType.ToLowerInvariant() switch + { + "user" => SetUserInstallRoot(), + _ => throw new InvalidOperationException($"Unknown install type: {_installType}") + }; + } + + [SupportedOSPlatform("windows")] + private void HandleWindowsAdminPath() + { + try + { + // Check if admin PATH needs to be changed + if (WindowsPathHelper.AdminPathContainsProgramFilesDotnet()) + { + Console.WriteLine("Program Files dotnet path found in admin PATH. Removing it..."); + + if (WindowsPathHelper.IsElevated()) + { + // We're already elevated, modify the admin PATH directly + Console.WriteLine("Running with elevated privileges. Modifying admin PATH..."); + string oldPath = WindowsPathHelper.ReadAdminPath(); + string newPath = WindowsPathHelper.RemoveProgramFilesDotnetFromPath(oldPath); + WindowsPathHelper.WriteAdminPath(newPath); + WindowsPathHelper.LogPathChange("SetInstallRoot user - Remove dotnet from admin PATH", oldPath, newPath); + WindowsPathHelper.BroadcastEnvironmentChange(); + Console.WriteLine("Successfully removed Program Files dotnet path from admin PATH."); + } + else + { + // Not elevated, shell out to elevated process + Console.WriteLine("Launching elevated process to modify admin PATH..."); + int exitCode = WindowsPathHelper.StartElevatedProcess("elevatedadminpath removedotnet"); + + if (exitCode != 0) + { + Console.Error.WriteLine("Warning: Failed to modify admin PATH. You may need to manually remove the Program Files dotnet path from the system PATH."); + // Continue anyway - we can still set up the user PATH + } + } + } + else + { + Console.WriteLine("Admin PATH does not contain Program Files dotnet path. No changes needed."); + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"Warning: Error while checking/modifying admin PATH: {ex.Message}"); + Console.Error.WriteLine("Continuing with user PATH setup..."); + } + } + + private int SetUserInstallRoot() + { + // Get the default user dotnet installation path + string userDotnetPath = _dotnetInstaller.GetDefaultDotnetInstallPath(); + + Console.WriteLine($"Setting up user install root at: {userDotnetPath}"); + + // On Windows, check if we need to modify the admin PATH + if (OperatingSystem.IsWindows()) + { + HandleWindowsAdminPath(); + } + + // Add the user dotnet path to the user PATH + try + { + Console.WriteLine($"Adding {userDotnetPath} to user PATH..."); + + var userPath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.User) ?? string.Empty; + var pathEntries = userPath.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries).ToList(); + + // Check if the user dotnet path is already in the user PATH + bool alreadyExists = pathEntries.Any(entry => + Path.TrimEndingDirectorySeparator(entry).Equals( + Path.TrimEndingDirectorySeparator(userDotnetPath), + StringComparison.OrdinalIgnoreCase)); + + if (!alreadyExists) + { + // Add to the beginning of PATH + pathEntries.Insert(0, userDotnetPath); + var newUserPath = string.Join(Path.PathSeparator, pathEntries); + Environment.SetEnvironmentVariable("PATH", newUserPath, EnvironmentVariableTarget.User); + Console.WriteLine($"Successfully added {userDotnetPath} to user PATH."); + } + else + { + Console.WriteLine($"User dotnet path is already in user PATH."); + } + + // Set DOTNET_ROOT for user + Environment.SetEnvironmentVariable("DOTNET_ROOT", userDotnetPath, EnvironmentVariableTarget.User); + Console.WriteLine($"Set DOTNET_ROOT to {userDotnetPath}"); + + Console.WriteLine("User install root configured successfully."); + Console.WriteLine("Note: You may need to restart your terminal or application for the changes to take effect."); + + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error: Failed to configure user install root: {ex.Message}"); + return 1; + } + } +} diff --git a/src/Installer/dotnetup/Commands/SetInstallRoot/SetInstallRootCommandParser.cs b/src/Installer/dotnetup/Commands/SetInstallRoot/SetInstallRootCommandParser.cs new file mode 100644 index 000000000000..cae249391b93 --- /dev/null +++ b/src/Installer/dotnetup/Commands/SetInstallRoot/SetInstallRootCommandParser.cs @@ -0,0 +1,34 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; + +namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.SetInstallRoot; + +internal static class SetInstallRootCommandParser +{ + public static readonly Argument InstallTypeArgument = new("installtype") + { + HelpName = "INSTALL_TYPE", + Description = "The type of installation root to set: 'user'", + Arity = ArgumentArity.ExactlyOne, + }; + + private static readonly Command SetInstallRootCommand = ConstructCommand(); + + public static Command GetCommand() + { + return SetInstallRootCommand; + } + + private static Command ConstructCommand() + { + Command command = new("setinstallroot", "Sets the dotnet installation root"); + + command.Arguments.Add(InstallTypeArgument); + + command.SetAction(parseResult => new SetInstallRootCommand(parseResult).Execute()); + + return command; + } +} diff --git a/src/Installer/dotnetup/Parser.cs b/src/Installer/dotnetup/Parser.cs index 7032270df288..1ff3898f367e 100644 --- a/src/Installer/dotnetup/Parser.cs +++ b/src/Installer/dotnetup/Parser.cs @@ -9,6 +9,8 @@ using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk; using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Update; +using Microsoft.DotNet.Tools.Bootstrapper.Commands.ElevatedAdminPath; +using Microsoft.DotNet.Tools.Bootstrapper.Commands.SetInstallRoot; namespace Microsoft.DotNet.Tools.Bootstrapper { @@ -38,6 +40,8 @@ private static RootCommand ConfigureCommandLine(RootCommand rootCommand) rootCommand.Subcommands.Add(SdkCommandParser.GetCommand()); rootCommand.Subcommands.Add(SdkInstallCommandParser.GetRootInstallCommand()); rootCommand.Subcommands.Add(SdkUpdateCommandParser.GetRootUpdateCommand()); + rootCommand.Subcommands.Add(ElevatedAdminPathCommandParser.GetCommand()); + rootCommand.Subcommands.Add(SetInstallRootCommandParser.GetCommand()); return rootCommand; } diff --git a/src/Installer/dotnetup/WindowsPathHelper.cs b/src/Installer/dotnetup/WindowsPathHelper.cs new file mode 100644 index 000000000000..2147f4ef3732 --- /dev/null +++ b/src/Installer/dotnetup/WindowsPathHelper.cs @@ -0,0 +1,263 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Security.Principal; +using Microsoft.Win32; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +/// +/// Helper class for Windows-specific PATH management operations. +/// +[SupportedOSPlatform("windows")] +internal static class WindowsPathHelper +{ + private const string RegistryEnvironmentPath = @"SYSTEM\CurrentControlSet\Control\Session Manager\Environment"; + private const string PathVariableName = "Path"; + private const int HWND_BROADCAST = 0xffff; + private const int WM_SETTINGCHANGE = 0x001A; + private const int SMTO_ABORTIFHUNG = 0x0002; + + [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)] + private static extern IntPtr SendMessageTimeout( + IntPtr hWnd, + int Msg, + IntPtr wParam, + string lParam, + int fuFlags, + int uTimeout, + out IntPtr lpdwResult); + + /// + /// Checks if the current process is running with elevated (administrator) privileges. + /// + public static bool IsElevated() + { + using var identity = WindowsIdentity.GetCurrent(); + var principal = new WindowsPrincipal(identity); + return principal.IsInRole(WindowsBuiltInRole.Administrator); + } + + /// + /// Reads the machine-wide PATH environment variable from the registry. + /// + public static string ReadAdminPath() + { + using var key = Registry.LocalMachine.OpenSubKey(RegistryEnvironmentPath, writable: false); + if (key == null) + { + throw new InvalidOperationException("Unable to open registry key for environment variables."); + } + + var pathValue = key.GetValue(PathVariableName) as string; + return pathValue ?? string.Empty; + } + + /// + /// Writes the machine-wide PATH environment variable to the registry. + /// + public static void WriteAdminPath(string path) + { + using var key = Registry.LocalMachine.OpenSubKey(RegistryEnvironmentPath, writable: true); + if (key == null) + { + throw new InvalidOperationException("Unable to open registry key for environment variables. Administrator privileges required."); + } + + key.SetValue(PathVariableName, path, RegistryValueKind.ExpandString); + } + + /// + /// Gets the default Program Files dotnet installation path(s). + /// + public static List GetProgramFilesDotnetPaths() + { + var paths = new List(); + + string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + if (!string.IsNullOrEmpty(programFiles)) + { + paths.Add(Path.Combine(programFiles, "dotnet")); + } + + // On 64-bit Windows, also check Program Files (x86) + string programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); + if (!string.IsNullOrEmpty(programFilesX86) && !programFilesX86.Equals(programFiles, StringComparison.OrdinalIgnoreCase)) + { + paths.Add(Path.Combine(programFilesX86, "dotnet")); + } + + return paths; + } + + /// + /// Removes the Program Files dotnet path from the given PATH string. + /// + public static string RemoveProgramFilesDotnetFromPath(string path) + { + var pathEntries = path.Split(';', StringSplitOptions.RemoveEmptyEntries).ToList(); + var programFilesDotnetPaths = GetProgramFilesDotnetPaths(); + + // Remove entries that match Program Files dotnet paths (case-insensitive) + pathEntries = pathEntries.Where(entry => + { + var normalizedEntry = Path.TrimEndingDirectorySeparator(entry); + return !programFilesDotnetPaths.Any(pfPath => + normalizedEntry.Equals(Path.TrimEndingDirectorySeparator(pfPath), StringComparison.OrdinalIgnoreCase)); + }).ToList(); + + return string.Join(';', pathEntries); + } + + /// + /// Adds the Program Files dotnet path to the given PATH string if it's not already present. + /// + public static string AddProgramFilesDotnetToPath(string path) + { + var pathEntries = path.Split(';', StringSplitOptions.RemoveEmptyEntries).ToList(); + var programFilesDotnetPaths = GetProgramFilesDotnetPaths(); + + // Get the primary Program Files dotnet path (non-x86) + string primaryDotnetPath = programFilesDotnetPaths.FirstOrDefault() ?? string.Empty; + if (string.IsNullOrEmpty(primaryDotnetPath)) + { + return path; + } + + // Check if any Program Files dotnet path is already in PATH + bool alreadyExists = pathEntries.Any(entry => + { + var normalizedEntry = Path.TrimEndingDirectorySeparator(entry); + return programFilesDotnetPaths.Any(pfPath => + normalizedEntry.Equals(Path.TrimEndingDirectorySeparator(pfPath), StringComparison.OrdinalIgnoreCase)); + }); + + if (!alreadyExists) + { + pathEntries.Insert(0, primaryDotnetPath); + } + + return string.Join(';', pathEntries); + } + + /// + /// Checks if the admin PATH contains the Program Files dotnet path. + /// + public static bool AdminPathContainsProgramFilesDotnet() + { + var adminPath = ReadAdminPath(); + var pathEntries = adminPath.Split(';', StringSplitOptions.RemoveEmptyEntries); + var programFilesDotnetPaths = GetProgramFilesDotnetPaths(); + + return pathEntries.Any(entry => + { + var normalizedEntry = Path.TrimEndingDirectorySeparator(entry); + return programFilesDotnetPaths.Any(pfPath => + normalizedEntry.Equals(Path.TrimEndingDirectorySeparator(pfPath), StringComparison.OrdinalIgnoreCase)); + }); + } + + /// + /// Logs PATH changes to a file in the temp directory. + /// + public static void LogPathChange(string operation, string oldPath, string newPath) + { + try + { + string tempPath = Path.GetTempPath(); + string logFileName = $"dotnetup_path_changes_{DateTime.Now:yyyyMMdd}.log"; + string logFilePath = Path.Combine(tempPath, logFileName); + + string logEntry = $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] Operation: {operation}{Environment.NewLine}" + + $"Old PATH: {oldPath}{Environment.NewLine}" + + $"New PATH: {newPath}{Environment.NewLine}" + + $"----------------------------------------{Environment.NewLine}"; + + File.AppendAllText(logFilePath, logEntry); + + Console.WriteLine($"PATH changes logged to: {logFilePath}"); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Warning: Failed to log PATH changes: {ex.Message}"); + } + } + + + + /// + /// Broadcasts a WM_SETTINGCHANGE message to notify other applications that the environment has changed. + /// + public static void BroadcastEnvironmentChange() + { + try + { + SendMessageTimeout( + new IntPtr(HWND_BROADCAST), + WM_SETTINGCHANGE, + IntPtr.Zero, + "Environment", + SMTO_ABORTIFHUNG, + 5000, + out IntPtr result); + + Console.WriteLine("Environment change notification broadcasted."); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Warning: Failed to broadcast environment change: {ex.Message}"); + } + } + + /// + /// Starts an elevated process with the given arguments. + /// + public static int StartElevatedProcess(string arguments) + { + try + { + var processPath = Environment.ProcessPath; + if (string.IsNullOrEmpty(processPath)) + { + Console.Error.WriteLine("Error: Unable to determine current process path."); + return 1; + } + + var startInfo = new ProcessStartInfo + { + FileName = processPath, + Arguments = arguments, + Verb = "runas", // This triggers UAC elevation + UseShellExecute = true + }; + + using var process = Process.Start(startInfo); + if (process == null) + { + Console.Error.WriteLine("Error: Failed to start elevated process."); + return 1; + } + + process.WaitForExit(); + return process.ExitCode; + } + catch (System.ComponentModel.Win32Exception ex) + { + // User cancelled UAC prompt + Console.Error.WriteLine($"Error: Elevation cancelled or failed: {ex.Message}"); + return 1; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error: Failed to start elevated process: {ex.Message}"); + return 1; + } + } +} From d8fed6abb71a7ed0cf86f26fb09747de1ec89b27 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Dec 2025 14:29:27 +0000 Subject: [PATCH 03/14] Add tests for new PATH management commands - Added parser tests for elevatedadminpath and setinstallroot commands - Added unit tests for WindowsPathHelper utility methods - All tests pass successfully Co-authored-by: dsplaisted <145043+dsplaisted@users.noreply.github.com> --- test/dotnetup.Tests/ParserTests.cs | 28 +++++ test/dotnetup.Tests/WindowsPathHelperTests.cs | 115 ++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 test/dotnetup.Tests/WindowsPathHelperTests.cs diff --git a/test/dotnetup.Tests/ParserTests.cs b/test/dotnetup.Tests/ParserTests.cs index 73e85a233cee..6ea7a58e3fa3 100644 --- a/test/dotnetup.Tests/ParserTests.cs +++ b/test/dotnetup.Tests/ParserTests.cs @@ -62,4 +62,32 @@ public void Parser_ShouldHandleRootHelp() parseResult.Should().NotBeNull(); parseResult.Errors.Should().BeEmpty(); } + + [Fact] + public void Parser_ShouldParseElevatedAdminPathCommand() + { + // Arrange + var args = new[] { "elevatedadminpath", "removedotnet" }; + + // Act + var parseResult = Parser.Parse(args); + + // Assert + parseResult.Should().NotBeNull(); + parseResult.Errors.Should().BeEmpty(); + } + + [Fact] + public void Parser_ShouldParseSetInstallRootCommand() + { + // Arrange + var args = new[] { "setinstallroot", "user" }; + + // Act + var parseResult = Parser.Parse(args); + + // Assert + parseResult.Should().NotBeNull(); + parseResult.Errors.Should().BeEmpty(); + } } diff --git a/test/dotnetup.Tests/WindowsPathHelperTests.cs b/test/dotnetup.Tests/WindowsPathHelperTests.cs new file mode 100644 index 000000000000..a1bf53c2460f --- /dev/null +++ b/test/dotnetup.Tests/WindowsPathHelperTests.cs @@ -0,0 +1,115 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.DotNet.Tools.Bootstrapper; + +namespace Microsoft.DotNet.Tools.Dotnetup.Tests; + +public class WindowsPathHelperTests +{ + [Fact] + public void RemoveProgramFilesDotnetFromPath_RemovesCorrectPath() + { + // This test can only run on Windows where WindowsPathHelper is supported + if (!OperatingSystem.IsWindows()) + { + return; + } + + // Arrange + string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + string dotnetPath = Path.Combine(programFiles, "dotnet"); + string path = $"C:\\SomeOtherPath;{dotnetPath};C:\\AnotherPath"; + + // Act + string result = WindowsPathHelper.RemoveProgramFilesDotnetFromPath(path); + + // Assert + result.Should().NotContain(dotnetPath); + result.Should().Contain("C:\\SomeOtherPath"); + result.Should().Contain("C:\\AnotherPath"); + } + + [Fact] + public void RemoveProgramFilesDotnetFromPath_HandlesEmptyPath() + { + // This test can only run on Windows where WindowsPathHelper is supported + if (!OperatingSystem.IsWindows()) + { + return; + } + + // Arrange + string path = string.Empty; + + // Act + string result = WindowsPathHelper.RemoveProgramFilesDotnetFromPath(path); + + // Assert + result.Should().BeEmpty(); + } + + [Fact] + public void AddProgramFilesDotnetToPath_AddsCorrectPath() + { + // This test can only run on Windows where WindowsPathHelper is supported + if (!OperatingSystem.IsWindows()) + { + return; + } + + // Arrange + string path = "C:\\SomeOtherPath;C:\\AnotherPath"; + + // Act + string result = WindowsPathHelper.AddProgramFilesDotnetToPath(path); + + // Assert + string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + string dotnetPath = Path.Combine(programFiles, "dotnet"); + result.Should().Contain(dotnetPath); + result.Should().Contain("C:\\SomeOtherPath"); + result.Should().Contain("C:\\AnotherPath"); + } + + [Fact] + public void AddProgramFilesDotnetToPath_DoesNotAddDuplicatePath() + { + // This test can only run on Windows where WindowsPathHelper is supported + if (!OperatingSystem.IsWindows()) + { + return; + } + + // Arrange + string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + string dotnetPath = Path.Combine(programFiles, "dotnet"); + string path = $"C:\\SomeOtherPath;{dotnetPath};C:\\AnotherPath"; + + // Act + string result = WindowsPathHelper.AddProgramFilesDotnetToPath(path); + + // Assert + // Count occurrences of dotnetPath in result + int count = result.Split(';').Count(p => p.Equals(dotnetPath, StringComparison.OrdinalIgnoreCase)); + count.Should().Be(1); + } + + [Fact] + public void GetProgramFilesDotnetPaths_ReturnsValidPaths() + { + // This test can only run on Windows where WindowsPathHelper is supported + if (!OperatingSystem.IsWindows()) + { + return; + } + + // Act + var paths = WindowsPathHelper.GetProgramFilesDotnetPaths(); + + // Assert + paths.Should().NotBeNull(); + paths.Should().NotBeEmpty(); + paths.Should().AllSatisfy(p => p.Should().EndWith("dotnet")); + } +} From 374d46ecdb45f5d9b51b3352ef997b13fe4771d4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Dec 2025 14:33:08 +0000 Subject: [PATCH 04/14] Address code review feedback - Extract PATH splitting logic to SplitPath helper method - Remove extra blank lines in WindowsPathHelper.cs - All tests still pass Co-authored-by: dsplaisted <145043+dsplaisted@users.noreply.github.com> --- src/Installer/dotnetup/WindowsPathHelper.cs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/Installer/dotnetup/WindowsPathHelper.cs b/src/Installer/dotnetup/WindowsPathHelper.cs index 2147f4ef3732..797dd44ca45d 100644 --- a/src/Installer/dotnetup/WindowsPathHelper.cs +++ b/src/Installer/dotnetup/WindowsPathHelper.cs @@ -97,12 +97,20 @@ public static List GetProgramFilesDotnetPaths() return paths; } + /// + /// Splits a PATH string into entries. + /// + private static List SplitPath(string path) + { + return path.Split(';', StringSplitOptions.RemoveEmptyEntries).ToList(); + } + /// /// Removes the Program Files dotnet path from the given PATH string. /// public static string RemoveProgramFilesDotnetFromPath(string path) { - var pathEntries = path.Split(';', StringSplitOptions.RemoveEmptyEntries).ToList(); + var pathEntries = SplitPath(path); var programFilesDotnetPaths = GetProgramFilesDotnetPaths(); // Remove entries that match Program Files dotnet paths (case-insensitive) @@ -121,7 +129,7 @@ public static string RemoveProgramFilesDotnetFromPath(string path) /// public static string AddProgramFilesDotnetToPath(string path) { - var pathEntries = path.Split(';', StringSplitOptions.RemoveEmptyEntries).ToList(); + var pathEntries = SplitPath(path); var programFilesDotnetPaths = GetProgramFilesDotnetPaths(); // Get the primary Program Files dotnet path (non-x86) @@ -153,7 +161,7 @@ public static string AddProgramFilesDotnetToPath(string path) public static bool AdminPathContainsProgramFilesDotnet() { var adminPath = ReadAdminPath(); - var pathEntries = adminPath.Split(';', StringSplitOptions.RemoveEmptyEntries); + var pathEntries = SplitPath(adminPath); var programFilesDotnetPaths = GetProgramFilesDotnetPaths(); return pathEntries.Any(entry => @@ -190,8 +198,6 @@ public static void LogPathChange(string operation, string oldPath, string newPat } } - - /// /// Broadcasts a WM_SETTINGCHANGE message to notify other applications that the environment has changed. /// From 6174dec988e7c0c4417e81a8583879ed26f40584 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Dec 2025 14:34:20 +0000 Subject: [PATCH 05/14] Run dotnet format to fix whitespace Co-authored-by: dsplaisted <145043+dsplaisted@users.noreply.github.com> --- .../Commands/SetInstallRoot/SetInstallRootCommand.cs | 6 +++--- src/Installer/dotnetup/WindowsPathHelper.cs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Installer/dotnetup/Commands/SetInstallRoot/SetInstallRootCommand.cs b/src/Installer/dotnetup/Commands/SetInstallRoot/SetInstallRootCommand.cs index 1a3283756347..40cb1fc061d6 100644 --- a/src/Installer/dotnetup/Commands/SetInstallRoot/SetInstallRootCommand.cs +++ b/src/Installer/dotnetup/Commands/SetInstallRoot/SetInstallRootCommand.cs @@ -53,7 +53,7 @@ private void HandleWindowsAdminPath() // Not elevated, shell out to elevated process Console.WriteLine("Launching elevated process to modify admin PATH..."); int exitCode = WindowsPathHelper.StartElevatedProcess("elevatedadminpath removedotnet"); - + if (exitCode != 0) { Console.Error.WriteLine("Warning: Failed to modify admin PATH. You may need to manually remove the Program Files dotnet path from the system PATH."); @@ -90,7 +90,7 @@ private int SetUserInstallRoot() try { Console.WriteLine($"Adding {userDotnetPath} to user PATH..."); - + var userPath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.User) ?? string.Empty; var pathEntries = userPath.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries).ToList(); @@ -119,7 +119,7 @@ private int SetUserInstallRoot() Console.WriteLine("User install root configured successfully."); Console.WriteLine("Note: You may need to restart your terminal or application for the changes to take effect."); - + return 0; } catch (Exception ex) diff --git a/src/Installer/dotnetup/WindowsPathHelper.cs b/src/Installer/dotnetup/WindowsPathHelper.cs index 797dd44ca45d..2c8d13709c71 100644 --- a/src/Installer/dotnetup/WindowsPathHelper.cs +++ b/src/Installer/dotnetup/WindowsPathHelper.cs @@ -80,7 +80,7 @@ public static void WriteAdminPath(string path) public static List GetProgramFilesDotnetPaths() { var paths = new List(); - + string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); if (!string.IsNullOrEmpty(programFiles)) { From 3d163b53453cedf1fb2bb6f58df06d35cee24984 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Dec 2025 15:09:21 +0000 Subject: [PATCH 06/14] Hide elevatedadminpath command from help output The elevatedadminpath command is now hidden from the main help output as it's an internal command not meant to be invoked directly by users. The command is still functional when invoked explicitly. Co-authored-by: dsplaisted <145043+dsplaisted@users.noreply.github.com> --- .../Commands/ElevatedAdminPath/ElevatedAdminPathCommandParser.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Installer/dotnetup/Commands/ElevatedAdminPath/ElevatedAdminPathCommandParser.cs b/src/Installer/dotnetup/Commands/ElevatedAdminPath/ElevatedAdminPathCommandParser.cs index f7d48e7a7016..7b8f406e95e8 100644 --- a/src/Installer/dotnetup/Commands/ElevatedAdminPath/ElevatedAdminPathCommandParser.cs +++ b/src/Installer/dotnetup/Commands/ElevatedAdminPath/ElevatedAdminPathCommandParser.cs @@ -24,6 +24,7 @@ public static Command GetCommand() private static Command ConstructCommand() { Command command = new("elevatedadminpath", "Modifies the machine-wide admin PATH (requires elevated privileges)"); + command.Hidden = true; command.Arguments.Add(OperationArgument); From 55853514141fbd685926e5727c54842d8b368481 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Dec 2025 15:34:06 +0000 Subject: [PATCH 07/14] Refactor PATH manipulation to preserve unexpanded environment variables - Modified ReadAdminPath to accept expand parameter controlling variable expansion - Created testable methods (FindDotnetPathIndices, RemovePathEntriesByIndices, PathContainsDotnet) - Added RemoveProgramFilesDotnetFromAdminPath that uses expanded PATH for detection but modifies unexpanded PATH - Updated AdminPathContainsProgramFilesDotnet to use expanded PATH for accurate detection - Updated commands to use new methods - Added 7 new unit tests for the testable methods - All 18 tests pass Co-authored-by: dsplaisted <145043+dsplaisted@users.noreply.github.com> --- .../ElevatedAdminPathCommand.cs | 6 +- .../SetInstallRoot/SetInstallRootCommand.cs | 4 +- src/Installer/dotnetup/WindowsPathHelper.cs | 101 ++++++++++-- test/dotnetup.Tests/WindowsPathHelperTests.cs | 145 ++++++++++++++++++ 4 files changed, 234 insertions(+), 22 deletions(-) diff --git a/src/Installer/dotnetup/Commands/ElevatedAdminPath/ElevatedAdminPathCommand.cs b/src/Installer/dotnetup/Commands/ElevatedAdminPath/ElevatedAdminPathCommand.cs index 8696dd052717..ed5c6613e58e 100644 --- a/src/Installer/dotnetup/Commands/ElevatedAdminPath/ElevatedAdminPathCommand.cs +++ b/src/Installer/dotnetup/Commands/ElevatedAdminPath/ElevatedAdminPathCommand.cs @@ -46,7 +46,7 @@ private int RemoveDotnet() try { Console.WriteLine("Reading current admin PATH from registry..."); - string oldPath = WindowsPathHelper.ReadAdminPath(); + string oldPath = WindowsPathHelper.ReadAdminPath(expand: false); if (!WindowsPathHelper.AdminPathContainsProgramFilesDotnet()) { @@ -55,7 +55,7 @@ private int RemoveDotnet() } Console.WriteLine("Removing Program Files dotnet path from admin PATH..."); - string newPath = WindowsPathHelper.RemoveProgramFilesDotnetFromPath(oldPath); + string newPath = WindowsPathHelper.RemoveProgramFilesDotnetFromAdminPath(); Console.WriteLine("Writing updated admin PATH to registry..."); WindowsPathHelper.WriteAdminPath(newPath); @@ -82,7 +82,7 @@ private int AddDotnet() try { Console.WriteLine("Reading current admin PATH from registry..."); - string oldPath = WindowsPathHelper.ReadAdminPath(); + string oldPath = WindowsPathHelper.ReadAdminPath(expand: false); if (WindowsPathHelper.AdminPathContainsProgramFilesDotnet()) { diff --git a/src/Installer/dotnetup/Commands/SetInstallRoot/SetInstallRootCommand.cs b/src/Installer/dotnetup/Commands/SetInstallRoot/SetInstallRootCommand.cs index 40cb1fc061d6..760631983f99 100644 --- a/src/Installer/dotnetup/Commands/SetInstallRoot/SetInstallRootCommand.cs +++ b/src/Installer/dotnetup/Commands/SetInstallRoot/SetInstallRootCommand.cs @@ -41,8 +41,8 @@ private void HandleWindowsAdminPath() { // We're already elevated, modify the admin PATH directly Console.WriteLine("Running with elevated privileges. Modifying admin PATH..."); - string oldPath = WindowsPathHelper.ReadAdminPath(); - string newPath = WindowsPathHelper.RemoveProgramFilesDotnetFromPath(oldPath); + string oldPath = WindowsPathHelper.ReadAdminPath(expand: false); + string newPath = WindowsPathHelper.RemoveProgramFilesDotnetFromAdminPath(); WindowsPathHelper.WriteAdminPath(newPath); WindowsPathHelper.LogPathChange("SetInstallRoot user - Remove dotnet from admin PATH", oldPath, newPath); WindowsPathHelper.BroadcastEnvironmentChange(); diff --git a/src/Installer/dotnetup/WindowsPathHelper.cs b/src/Installer/dotnetup/WindowsPathHelper.cs index 2c8d13709c71..9c2b133a0823 100644 --- a/src/Installer/dotnetup/WindowsPathHelper.cs +++ b/src/Installer/dotnetup/WindowsPathHelper.cs @@ -48,7 +48,8 @@ public static bool IsElevated() /// /// Reads the machine-wide PATH environment variable from the registry. /// - public static string ReadAdminPath() + /// If true, expands environment variables in the PATH value. + public static string ReadAdminPath(bool expand = false) { using var key = Registry.LocalMachine.OpenSubKey(RegistryEnvironmentPath, writable: false); if (key == null) @@ -56,7 +57,7 @@ public static string ReadAdminPath() throw new InvalidOperationException("Unable to open registry key for environment variables."); } - var pathValue = key.GetValue(PathVariableName) as string; + var pathValue = key.GetValue(PathVariableName, null, expand ? RegistryValueOptions.None : RegistryValueOptions.DoNotExpandEnvironmentNames) as string; return pathValue ?? string.Empty; } @@ -105,23 +106,93 @@ private static List SplitPath(string path) return path.Split(';', StringSplitOptions.RemoveEmptyEntries).ToList(); } + /// + /// Finds the indices of entries in a PATH that match the Program Files dotnet paths. + /// This method is designed for unit testing without registry access. + /// + /// The list of PATH entries to search. + /// The list of Program Files dotnet paths to match. + /// A list of indices where dotnet paths were found. + public static List FindDotnetPathIndices(List pathEntries, List programFilesDotnetPaths) + { + var indices = new List(); + for (int i = 0; i < pathEntries.Count; i++) + { + var normalizedEntry = Path.TrimEndingDirectorySeparator(pathEntries[i]); + if (programFilesDotnetPaths.Any(pfPath => + normalizedEntry.Equals(Path.TrimEndingDirectorySeparator(pfPath), StringComparison.OrdinalIgnoreCase))) + { + indices.Add(i); + } + } + return indices; + } + + /// + /// Removes entries at the specified indices from a PATH string. + /// This method is designed for unit testing without registry access. + /// + /// The PATH string to modify. + /// The indices of entries to remove. + /// The modified PATH string with entries removed. + public static string RemovePathEntriesByIndices(string path, List indicesToRemove) + { + if (indicesToRemove.Count == 0) + { + return path; + } + + var pathEntries = SplitPath(path); + var indicesToRemoveSet = new HashSet(indicesToRemove); + + var filteredEntries = pathEntries + .Where((entry, index) => !indicesToRemoveSet.Contains(index)) + .ToList(); + + return string.Join(';', filteredEntries); + } + + /// + /// Checks if a PATH contains any Program Files dotnet paths. + /// This method is designed for unit testing without registry access. + /// + /// The list of PATH entries to check. + /// The list of Program Files dotnet paths to match. + /// True if any dotnet path is found, false otherwise. + public static bool PathContainsDotnet(List pathEntries, List programFilesDotnetPaths) + { + return FindDotnetPathIndices(pathEntries, programFilesDotnetPaths).Count > 0; + } + /// /// Removes the Program Files dotnet path from the given PATH string. + /// This is a convenience method that uses the expanded PATH for detection. /// public static string RemoveProgramFilesDotnetFromPath(string path) { var pathEntries = SplitPath(path); var programFilesDotnetPaths = GetProgramFilesDotnetPaths(); + var indices = FindDotnetPathIndices(pathEntries, programFilesDotnetPaths); + return RemovePathEntriesByIndices(path, indices); + } - // Remove entries that match Program Files dotnet paths (case-insensitive) - pathEntries = pathEntries.Where(entry => - { - var normalizedEntry = Path.TrimEndingDirectorySeparator(entry); - return !programFilesDotnetPaths.Any(pfPath => - normalizedEntry.Equals(Path.TrimEndingDirectorySeparator(pfPath), StringComparison.OrdinalIgnoreCase)); - }).ToList(); + /// + /// Removes the Program Files dotnet path from the admin PATH while preserving unexpanded environment variables. + /// + /// The modified unexpanded PATH string. + public static string RemoveProgramFilesDotnetFromAdminPath() + { + // Read both expanded and unexpanded versions + string expandedPath = ReadAdminPath(expand: true); + string unexpandedPath = ReadAdminPath(expand: false); - return string.Join(';', pathEntries); + // Find indices to remove using the expanded path + var expandedEntries = SplitPath(expandedPath); + var programFilesDotnetPaths = GetProgramFilesDotnetPaths(); + var indicesToRemove = FindDotnetPathIndices(expandedEntries, programFilesDotnetPaths); + + // Remove those indices from the unexpanded path + return RemovePathEntriesByIndices(unexpandedPath, indicesToRemove); } /// @@ -157,19 +228,15 @@ public static string AddProgramFilesDotnetToPath(string path) /// /// Checks if the admin PATH contains the Program Files dotnet path. + /// Uses the expanded PATH for accurate detection. /// public static bool AdminPathContainsProgramFilesDotnet() { - var adminPath = ReadAdminPath(); + var adminPath = ReadAdminPath(expand: true); var pathEntries = SplitPath(adminPath); var programFilesDotnetPaths = GetProgramFilesDotnetPaths(); - return pathEntries.Any(entry => - { - var normalizedEntry = Path.TrimEndingDirectorySeparator(entry); - return programFilesDotnetPaths.Any(pfPath => - normalizedEntry.Equals(Path.TrimEndingDirectorySeparator(pfPath), StringComparison.OrdinalIgnoreCase)); - }); + return PathContainsDotnet(pathEntries, programFilesDotnetPaths); } /// diff --git a/test/dotnetup.Tests/WindowsPathHelperTests.cs b/test/dotnetup.Tests/WindowsPathHelperTests.cs index a1bf53c2460f..1bef70e69230 100644 --- a/test/dotnetup.Tests/WindowsPathHelperTests.cs +++ b/test/dotnetup.Tests/WindowsPathHelperTests.cs @@ -112,4 +112,149 @@ public void GetProgramFilesDotnetPaths_ReturnsValidPaths() paths.Should().NotBeEmpty(); paths.Should().AllSatisfy(p => p.Should().EndWith("dotnet")); } + + [Fact] + public void FindDotnetPathIndices_FindsCorrectIndices() + { + // This test can only run on Windows where WindowsPathHelper is supported + if (!OperatingSystem.IsWindows()) + { + return; + } + + // Arrange + var pathEntries = new List { "C:\\Path1", "C:\\Program Files\\dotnet", "C:\\Path2", "C:\\Program Files (x86)\\dotnet" }; + var dotnetPaths = new List { "C:\\Program Files\\dotnet", "C:\\Program Files (x86)\\dotnet" }; + + // Act + var indices = WindowsPathHelper.FindDotnetPathIndices(pathEntries, dotnetPaths); + + // Assert + indices.Should().HaveCount(2); + indices.Should().Contain(1); + indices.Should().Contain(3); + } + + [Fact] + public void RemovePathEntriesByIndices_RemovesCorrectEntries() + { + // This test can only run on Windows where WindowsPathHelper is supported + if (!OperatingSystem.IsWindows()) + { + return; + } + + // Arrange + string path = "C:\\Path1;C:\\Path2;C:\\Path3;C:\\Path4"; + var indicesToRemove = new List { 1, 3 }; + + // Act + string result = WindowsPathHelper.RemovePathEntriesByIndices(path, indicesToRemove); + + // Assert + result.Should().Be("C:\\Path1;C:\\Path3"); + } + + [Fact] + public void RemovePathEntriesByIndices_HandlesEmptyIndices() + { + // This test can only run on Windows where WindowsPathHelper is supported + if (!OperatingSystem.IsWindows()) + { + return; + } + + // Arrange + string path = "C:\\Path1;C:\\Path2;C:\\Path3"; + var indicesToRemove = new List(); + + // Act + string result = WindowsPathHelper.RemovePathEntriesByIndices(path, indicesToRemove); + + // Assert + result.Should().Be(path); + } + + [Fact] + public void PathContainsDotnet_ReturnsTrueWhenDotnetExists() + { + // This test can only run on Windows where WindowsPathHelper is supported + if (!OperatingSystem.IsWindows()) + { + return; + } + + // Arrange + var pathEntries = new List { "C:\\Path1", "C:\\Program Files\\dotnet", "C:\\Path2" }; + var dotnetPaths = new List { "C:\\Program Files\\dotnet" }; + + // Act + bool result = WindowsPathHelper.PathContainsDotnet(pathEntries, dotnetPaths); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public void PathContainsDotnet_ReturnsFalseWhenDotnetDoesNotExist() + { + // This test can only run on Windows where WindowsPathHelper is supported + if (!OperatingSystem.IsWindows()) + { + return; + } + + // Arrange + var pathEntries = new List { "C:\\Path1", "C:\\Path2", "C:\\Path3" }; + var dotnetPaths = new List { "C:\\Program Files\\dotnet" }; + + // Act + bool result = WindowsPathHelper.PathContainsDotnet(pathEntries, dotnetPaths); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void FindDotnetPathIndices_IsCaseInsensitive() + { + // This test can only run on Windows where WindowsPathHelper is supported + if (!OperatingSystem.IsWindows()) + { + return; + } + + // Arrange + var pathEntries = new List { "C:\\Path1", "c:\\program files\\dotnet", "C:\\Path2" }; + var dotnetPaths = new List { "C:\\Program Files\\dotnet" }; + + // Act + var indices = WindowsPathHelper.FindDotnetPathIndices(pathEntries, dotnetPaths); + + // Assert + indices.Should().HaveCount(1); + indices.Should().Contain(1); + } + + [Fact] + public void RemovePathEntriesByIndices_PreservesUnexpandedVariables() + { + // This test can only run on Windows where WindowsPathHelper is supported + if (!OperatingSystem.IsWindows()) + { + return; + } + + // Arrange + string path = "%SystemRoot%\\system32;C:\\Program Files\\dotnet;%USERPROFILE%\\bin"; + var indicesToRemove = new List { 1 }; + + // Act + string result = WindowsPathHelper.RemovePathEntriesByIndices(path, indicesToRemove); + + // Assert + result.Should().Be("%SystemRoot%\\system32;%USERPROFILE%\\bin"); + result.Should().Contain("%SystemRoot%"); + result.Should().Contain("%USERPROFILE%"); + } } From 1d91179c41bff50966d0b29cd9c41a3208fdf6d3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Dec 2025 17:46:52 +0000 Subject: [PATCH 08/14] Refactor WindowsPathHelper to be disposable with progressive logging - Convert WindowsPathHelper from static to instance class implementing IDisposable - Add constructor that creates and opens log file on creation - Add Dispose method that closes log file and displays path - Add LogMessage method for progressive logging throughout operations - Create RemoveDotnetFromAdminPath() and AddDotnetToAdminPath() orchestrating methods - These methods encapsulate full logic: read, check, modify, write, log, broadcast - Simplify ElevatedAdminPathCommand to just create helper and call methods - Simplify SetInstallRootCommand to use the same logic - Log messages now written progressively during operations - All 18 tests pass Co-authored-by: dsplaisted <145043+dsplaisted@users.noreply.github.com> --- .../ElevatedAdminPathCommand.cs | 66 +------- .../SetInstallRoot/SetInstallRootCommand.cs | 8 +- src/Installer/dotnetup/WindowsPathHelper.cs | 153 ++++++++++++++++-- 3 files changed, 145 insertions(+), 82 deletions(-) diff --git a/src/Installer/dotnetup/Commands/ElevatedAdminPath/ElevatedAdminPathCommand.cs b/src/Installer/dotnetup/Commands/ElevatedAdminPath/ElevatedAdminPathCommand.cs index ed5c6613e58e..2db2b09f4c33 100644 --- a/src/Installer/dotnetup/Commands/ElevatedAdminPath/ElevatedAdminPathCommand.cs +++ b/src/Installer/dotnetup/Commands/ElevatedAdminPath/ElevatedAdminPathCommand.cs @@ -43,72 +43,14 @@ public override int Execute() [SupportedOSPlatform("windows")] private int RemoveDotnet() { - try - { - Console.WriteLine("Reading current admin PATH from registry..."); - string oldPath = WindowsPathHelper.ReadAdminPath(expand: false); - - if (!WindowsPathHelper.AdminPathContainsProgramFilesDotnet()) - { - Console.WriteLine("Program Files dotnet path is not present in admin PATH. No changes needed."); - return 0; - } - - Console.WriteLine("Removing Program Files dotnet path from admin PATH..."); - string newPath = WindowsPathHelper.RemoveProgramFilesDotnetFromAdminPath(); - - Console.WriteLine("Writing updated admin PATH to registry..."); - WindowsPathHelper.WriteAdminPath(newPath); - - // Log the changes - WindowsPathHelper.LogPathChange("Remove dotnet from admin PATH", oldPath, newPath); - - // Broadcast environment change - WindowsPathHelper.BroadcastEnvironmentChange(); - - Console.WriteLine("Successfully removed Program Files dotnet path from admin PATH."); - return 0; - } - catch (Exception ex) - { - Console.Error.WriteLine($"Error: Failed to remove dotnet from admin PATH: {ex.Message}"); - return 1; - } + using var pathHelper = new WindowsPathHelper(); + return pathHelper.RemoveDotnetFromAdminPath(); } [SupportedOSPlatform("windows")] private int AddDotnet() { - try - { - Console.WriteLine("Reading current admin PATH from registry..."); - string oldPath = WindowsPathHelper.ReadAdminPath(expand: false); - - if (WindowsPathHelper.AdminPathContainsProgramFilesDotnet()) - { - Console.WriteLine("Program Files dotnet path is already present in admin PATH. No changes needed."); - return 0; - } - - Console.WriteLine("Adding Program Files dotnet path to admin PATH..."); - string newPath = WindowsPathHelper.AddProgramFilesDotnetToPath(oldPath); - - Console.WriteLine("Writing updated admin PATH to registry..."); - WindowsPathHelper.WriteAdminPath(newPath); - - // Log the changes - WindowsPathHelper.LogPathChange("Add dotnet to admin PATH", oldPath, newPath); - - // Broadcast environment change - WindowsPathHelper.BroadcastEnvironmentChange(); - - Console.WriteLine("Successfully added Program Files dotnet path to admin PATH."); - return 0; - } - catch (Exception ex) - { - Console.Error.WriteLine($"Error: Failed to add dotnet to admin PATH: {ex.Message}"); - return 1; - } + using var pathHelper = new WindowsPathHelper(); + return pathHelper.AddDotnetToAdminPath(); } } diff --git a/src/Installer/dotnetup/Commands/SetInstallRoot/SetInstallRootCommand.cs b/src/Installer/dotnetup/Commands/SetInstallRoot/SetInstallRootCommand.cs index 760631983f99..ce214874c8f8 100644 --- a/src/Installer/dotnetup/Commands/SetInstallRoot/SetInstallRootCommand.cs +++ b/src/Installer/dotnetup/Commands/SetInstallRoot/SetInstallRootCommand.cs @@ -41,12 +41,8 @@ private void HandleWindowsAdminPath() { // We're already elevated, modify the admin PATH directly Console.WriteLine("Running with elevated privileges. Modifying admin PATH..."); - string oldPath = WindowsPathHelper.ReadAdminPath(expand: false); - string newPath = WindowsPathHelper.RemoveProgramFilesDotnetFromAdminPath(); - WindowsPathHelper.WriteAdminPath(newPath); - WindowsPathHelper.LogPathChange("SetInstallRoot user - Remove dotnet from admin PATH", oldPath, newPath); - WindowsPathHelper.BroadcastEnvironmentChange(); - Console.WriteLine("Successfully removed Program Files dotnet path from admin PATH."); + using var pathHelper = new WindowsPathHelper(); + pathHelper.RemoveDotnetFromAdminPath(); } else { diff --git a/src/Installer/dotnetup/WindowsPathHelper.cs b/src/Installer/dotnetup/WindowsPathHelper.cs index 9c2b133a0823..bf3268ba8dda 100644 --- a/src/Installer/dotnetup/WindowsPathHelper.cs +++ b/src/Installer/dotnetup/WindowsPathHelper.cs @@ -17,7 +17,7 @@ namespace Microsoft.DotNet.Tools.Bootstrapper; /// Helper class for Windows-specific PATH management operations. /// [SupportedOSPlatform("windows")] -internal static class WindowsPathHelper +internal sealed class WindowsPathHelper : IDisposable { private const string RegistryEnvironmentPath = @"SYSTEM\CurrentControlSet\Control\Session Manager\Environment"; private const string PathVariableName = "Path"; @@ -25,6 +25,10 @@ internal static class WindowsPathHelper private const int WM_SETTINGCHANGE = 0x001A; private const int SMTO_ABORTIFHUNG = 0x0002; + private readonly StreamWriter? _logWriter; + private readonly string? _logFilePath; + private bool _disposed; + [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)] private static extern IntPtr SendMessageTimeout( IntPtr hWnd, @@ -35,6 +39,57 @@ private static extern IntPtr SendMessageTimeout( int uTimeout, out IntPtr lpdwResult); + /// + /// Creates a new instance of WindowsPathHelper with logging enabled. + /// + public WindowsPathHelper() + { + try + { + string tempPath = Path.GetTempPath(); + string logFileName = $"dotnetup_path_changes_{DateTime.Now:yyyyMMdd}.log"; + _logFilePath = Path.Combine(tempPath, logFileName); + _logWriter = new StreamWriter(_logFilePath, append: true); + _logWriter.AutoFlush = true; + LogMessage($"=== WindowsPathHelper session started at {DateTime.Now:yyyy-MM-dd HH:mm:ss} ==="); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Warning: Failed to create log file: {ex.Message}"); + _logWriter = null; + _logFilePath = null; + } + } + + /// + /// Logs a message to the log file. + /// + private void LogMessage(string message) + { + try + { + _logWriter?.WriteLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {message}"); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Warning: Failed to write to log: {ex.Message}"); + } + } + + public void Dispose() + { + if (!_disposed) + { + LogMessage($"=== WindowsPathHelper session ended at {DateTime.Now:yyyy-MM-dd HH:mm:ss} ==="); + _logWriter?.Dispose(); + if (_logFilePath != null) + { + Console.WriteLine($"PATH changes logged to: {_logFilePath}"); + } + _disposed = true; + } + } + /// /// Checks if the current process is running with elevated (administrator) privileges. /// @@ -240,35 +295,105 @@ public static bool AdminPathContainsProgramFilesDotnet() } /// - /// Logs PATH changes to a file in the temp directory. + /// Removes the Program Files dotnet path from the admin PATH. + /// This is the main orchestrating method that should be called by commands. /// - public static void LogPathChange(string operation, string oldPath, string newPath) + /// 0 on success, 1 on failure. + public int RemoveDotnetFromAdminPath() { try { - string tempPath = Path.GetTempPath(); - string logFileName = $"dotnetup_path_changes_{DateTime.Now:yyyyMMdd}.log"; - string logFilePath = Path.Combine(tempPath, logFileName); + LogMessage("Starting RemoveDotnetFromAdminPath operation"); + Console.WriteLine("Reading current admin PATH from registry..."); + + string oldPath = ReadAdminPath(expand: false); + LogMessage($"Old PATH (unexpanded): {oldPath}"); + + if (!AdminPathContainsProgramFilesDotnet()) + { + Console.WriteLine("Program Files dotnet path is not present in admin PATH. No changes needed."); + LogMessage("No changes needed - dotnet path not found"); + return 0; + } - string logEntry = $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] Operation: {operation}{Environment.NewLine}" + - $"Old PATH: {oldPath}{Environment.NewLine}" + - $"New PATH: {newPath}{Environment.NewLine}" + - $"----------------------------------------{Environment.NewLine}"; + Console.WriteLine("Removing Program Files dotnet path from admin PATH..."); + LogMessage("Removing dotnet paths from admin PATH"); + string newPath = RemoveProgramFilesDotnetFromAdminPath(); + LogMessage($"New PATH (unexpanded): {newPath}"); - File.AppendAllText(logFilePath, logEntry); + Console.WriteLine("Writing updated admin PATH to registry..."); + WriteAdminPath(newPath); + LogMessage("PATH written to registry"); - Console.WriteLine($"PATH changes logged to: {logFilePath}"); + // Broadcast environment change + BroadcastEnvironmentChange(); + LogMessage("Environment change broadcasted"); + + Console.WriteLine("Successfully removed Program Files dotnet path from admin PATH."); + LogMessage("RemoveDotnetFromAdminPath operation completed successfully"); + return 0; } catch (Exception ex) { - Console.Error.WriteLine($"Warning: Failed to log PATH changes: {ex.Message}"); + Console.Error.WriteLine($"Error: Failed to remove dotnet from admin PATH: {ex.Message}"); + LogMessage($"ERROR: {ex.Message}"); + LogMessage($"Stack trace: {ex.StackTrace}"); + return 1; + } + } + + /// + /// Adds the Program Files dotnet path to the admin PATH. + /// This is the main orchestrating method that should be called by commands. + /// + /// 0 on success, 1 on failure. + public int AddDotnetToAdminPath() + { + try + { + LogMessage("Starting AddDotnetToAdminPath operation"); + Console.WriteLine("Reading current admin PATH from registry..."); + + string oldPath = ReadAdminPath(expand: false); + LogMessage($"Old PATH (unexpanded): {oldPath}"); + + if (AdminPathContainsProgramFilesDotnet()) + { + Console.WriteLine("Program Files dotnet path is already present in admin PATH. No changes needed."); + LogMessage("No changes needed - dotnet path already exists"); + return 0; + } + + Console.WriteLine("Adding Program Files dotnet path to admin PATH..."); + LogMessage("Adding dotnet path to admin PATH"); + string newPath = AddProgramFilesDotnetToPath(oldPath); + LogMessage($"New PATH (unexpanded): {newPath}"); + + Console.WriteLine("Writing updated admin PATH to registry..."); + WriteAdminPath(newPath); + LogMessage("PATH written to registry"); + + // Broadcast environment change + BroadcastEnvironmentChange(); + LogMessage("Environment change broadcasted"); + + Console.WriteLine("Successfully added Program Files dotnet path to admin PATH."); + LogMessage("AddDotnetToAdminPath operation completed successfully"); + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error: Failed to add dotnet to admin PATH: {ex.Message}"); + LogMessage($"ERROR: {ex.Message}"); + LogMessage($"Stack trace: {ex.StackTrace}"); + return 1; } } /// /// Broadcasts a WM_SETTINGCHANGE message to notify other applications that the environment has changed. /// - public static void BroadcastEnvironmentChange() + private void BroadcastEnvironmentChange() { try { From bbd3d5cadcf39353c023b16859401514092297a9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Dec 2025 02:23:53 +0000 Subject: [PATCH 09/14] Address code review feedback on WindowsPathHelper - Don't swallow exception in constructor, rethrow with better message preserving inner exception - Remove try-catch from LogMessage method, let it fail if there are issues - Remove console output from Dispose method - Refactor RemoveProgramFilesDotnetFromPath to take both unexpanded and expanded paths as parameters - Update RemoveProgramFilesDotnetFromAdminPath to call the refactored method - Replace custom logic in AddProgramFilesDotnetToPath with PathContainsDotnet method call - Update tests to pass both parameters to RemoveProgramFilesDotnetFromPath - All 18 tests pass Co-authored-by: dsplaisted <145043+dsplaisted@users.noreply.github.com> --- src/Installer/dotnetup/WindowsPathHelper.cs | 50 ++++++------------- test/dotnetup.Tests/WindowsPathHelperTests.cs | 8 +-- 2 files changed, 20 insertions(+), 38 deletions(-) diff --git a/src/Installer/dotnetup/WindowsPathHelper.cs b/src/Installer/dotnetup/WindowsPathHelper.cs index bf3268ba8dda..54c0fb08a5b1 100644 --- a/src/Installer/dotnetup/WindowsPathHelper.cs +++ b/src/Installer/dotnetup/WindowsPathHelper.cs @@ -55,9 +55,7 @@ public WindowsPathHelper() } catch (Exception ex) { - Console.Error.WriteLine($"Warning: Failed to create log file: {ex.Message}"); - _logWriter = null; - _logFilePath = null; + throw new InvalidOperationException("Failed to create log file for PATH changes.", ex); } } @@ -66,14 +64,7 @@ public WindowsPathHelper() /// private void LogMessage(string message) { - try - { - _logWriter?.WriteLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {message}"); - } - catch (Exception ex) - { - Console.Error.WriteLine($"Warning: Failed to write to log: {ex.Message}"); - } + _logWriter?.WriteLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {message}"); } public void Dispose() @@ -82,10 +73,6 @@ public void Dispose() { LogMessage($"=== WindowsPathHelper session ended at {DateTime.Now:yyyy-MM-dd HH:mm:ss} ==="); _logWriter?.Dispose(); - if (_logFilePath != null) - { - Console.WriteLine($"PATH changes logged to: {_logFilePath}"); - } _disposed = true; } } @@ -220,15 +207,21 @@ public static bool PathContainsDotnet(List pathEntries, List pro } /// - /// Removes the Program Files dotnet path from the given PATH string. - /// This is a convenience method that uses the expanded PATH for detection. + /// Removes the Program Files dotnet path from the given PATH strings. + /// Uses the expanded PATH for detection but modifies the unexpanded PATH to preserve environment variables. /// - public static string RemoveProgramFilesDotnetFromPath(string path) + /// The unexpanded PATH string to modify. + /// The expanded PATH string to use for detection. + /// The modified unexpanded PATH string. + public static string RemoveProgramFilesDotnetFromPath(string unexpandedPath, string expandedPath) { - var pathEntries = SplitPath(path); + // Find indices to remove using the expanded path + var expandedEntries = SplitPath(expandedPath); var programFilesDotnetPaths = GetProgramFilesDotnetPaths(); - var indices = FindDotnetPathIndices(pathEntries, programFilesDotnetPaths); - return RemovePathEntriesByIndices(path, indices); + var indicesToRemove = FindDotnetPathIndices(expandedEntries, programFilesDotnetPaths); + + // Remove those indices from the unexpanded path + return RemovePathEntriesByIndices(unexpandedPath, indicesToRemove); } /// @@ -241,13 +234,7 @@ public static string RemoveProgramFilesDotnetFromAdminPath() string expandedPath = ReadAdminPath(expand: true); string unexpandedPath = ReadAdminPath(expand: false); - // Find indices to remove using the expanded path - var expandedEntries = SplitPath(expandedPath); - var programFilesDotnetPaths = GetProgramFilesDotnetPaths(); - var indicesToRemove = FindDotnetPathIndices(expandedEntries, programFilesDotnetPaths); - - // Remove those indices from the unexpanded path - return RemovePathEntriesByIndices(unexpandedPath, indicesToRemove); + return RemoveProgramFilesDotnetFromPath(unexpandedPath, expandedPath); } /// @@ -266,12 +253,7 @@ public static string AddProgramFilesDotnetToPath(string path) } // Check if any Program Files dotnet path is already in PATH - bool alreadyExists = pathEntries.Any(entry => - { - var normalizedEntry = Path.TrimEndingDirectorySeparator(entry); - return programFilesDotnetPaths.Any(pfPath => - normalizedEntry.Equals(Path.TrimEndingDirectorySeparator(pfPath), StringComparison.OrdinalIgnoreCase)); - }); + bool alreadyExists = PathContainsDotnet(pathEntries, programFilesDotnetPaths); if (!alreadyExists) { diff --git a/test/dotnetup.Tests/WindowsPathHelperTests.cs b/test/dotnetup.Tests/WindowsPathHelperTests.cs index 1bef70e69230..67f5b7a62861 100644 --- a/test/dotnetup.Tests/WindowsPathHelperTests.cs +++ b/test/dotnetup.Tests/WindowsPathHelperTests.cs @@ -21,8 +21,8 @@ public void RemoveProgramFilesDotnetFromPath_RemovesCorrectPath() string dotnetPath = Path.Combine(programFiles, "dotnet"); string path = $"C:\\SomeOtherPath;{dotnetPath};C:\\AnotherPath"; - // Act - string result = WindowsPathHelper.RemoveProgramFilesDotnetFromPath(path); + // Act - pass the same path for both since no environment variables are used + string result = WindowsPathHelper.RemoveProgramFilesDotnetFromPath(path, path); // Assert result.Should().NotContain(dotnetPath); @@ -42,8 +42,8 @@ public void RemoveProgramFilesDotnetFromPath_HandlesEmptyPath() // Arrange string path = string.Empty; - // Act - string result = WindowsPathHelper.RemoveProgramFilesDotnetFromPath(path); + // Act - pass the same path for both since no environment variables are used + string result = WindowsPathHelper.RemoveProgramFilesDotnetFromPath(path, path); // Assert result.Should().BeEmpty(); From f4e92781d57340c9088ca4f5f53b5516e39c2cf1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Dec 2025 15:31:12 +0000 Subject: [PATCH 10/14] Address additional code review feedback - Rename RemoveProgramFilesDotnetFromAdminPath to GetAdminPathWithProgramFilesDotnetRemoved for clarity - Remove all Console.WriteLine statements from RemoveDotnetFromAdminPath and AddDotnetToAdminPath (keep only Console.Error) - Use ex.ToString() for complete exception logging including inner exceptions and stack traces - Update BroadcastEnvironmentChange to log instead of console output - Refactor StartElevatedProcess to return bool (true on success, false if cancelled) - Capture STDOUT and STDERR in StartElevatedProcess and throw exception with details on non-zero exit - Update SetInstallRootCommand to handle new StartElevatedProcess return type - All 18 tests pass Co-authored-by: dsplaisted <145043+dsplaisted@users.noreply.github.com> --- .../SetInstallRoot/SetInstallRootCommand.cs | 16 +++- src/Installer/dotnetup/WindowsPathHelper.cs | 94 +++++++++---------- 2 files changed, 59 insertions(+), 51 deletions(-) diff --git a/src/Installer/dotnetup/Commands/SetInstallRoot/SetInstallRootCommand.cs b/src/Installer/dotnetup/Commands/SetInstallRoot/SetInstallRootCommand.cs index ce214874c8f8..48259935fa73 100644 --- a/src/Installer/dotnetup/Commands/SetInstallRoot/SetInstallRootCommand.cs +++ b/src/Installer/dotnetup/Commands/SetInstallRoot/SetInstallRootCommand.cs @@ -48,11 +48,19 @@ private void HandleWindowsAdminPath() { // Not elevated, shell out to elevated process Console.WriteLine("Launching elevated process to modify admin PATH..."); - int exitCode = WindowsPathHelper.StartElevatedProcess("elevatedadminpath removedotnet"); - - if (exitCode != 0) + try + { + bool succeeded = WindowsPathHelper.StartElevatedProcess("elevatedadminpath removedotnet"); + if (!succeeded) + { + Console.Error.WriteLine("Warning: Elevation was cancelled. Admin PATH was not modified."); + // Continue anyway - we can still set up the user PATH + } + } + catch (InvalidOperationException ex) { - Console.Error.WriteLine("Warning: Failed to modify admin PATH. You may need to manually remove the Program Files dotnet path from the system PATH."); + Console.Error.WriteLine($"Warning: Failed to modify admin PATH: {ex.Message}"); + Console.Error.WriteLine("You may need to manually remove the Program Files dotnet path from the system PATH."); // Continue anyway - we can still set up the user PATH } } diff --git a/src/Installer/dotnetup/WindowsPathHelper.cs b/src/Installer/dotnetup/WindowsPathHelper.cs index 54c0fb08a5b1..f0c324f4e176 100644 --- a/src/Installer/dotnetup/WindowsPathHelper.cs +++ b/src/Installer/dotnetup/WindowsPathHelper.cs @@ -225,10 +225,11 @@ public static string RemoveProgramFilesDotnetFromPath(string unexpandedPath, str } /// - /// Removes the Program Files dotnet path from the admin PATH while preserving unexpanded environment variables. + /// Gets the admin PATH with Program Files dotnet path removed while preserving unexpanded environment variables. + /// This does not modify the registry, only returns the modified PATH string. /// /// The modified unexpanded PATH string. - public static string RemoveProgramFilesDotnetFromAdminPath() + public static string GetAdminPathWithProgramFilesDotnetRemoved() { // Read both expanded and unexpanded versions string expandedPath = ReadAdminPath(expand: true); @@ -286,40 +287,33 @@ public int RemoveDotnetFromAdminPath() try { LogMessage("Starting RemoveDotnetFromAdminPath operation"); - Console.WriteLine("Reading current admin PATH from registry..."); string oldPath = ReadAdminPath(expand: false); LogMessage($"Old PATH (unexpanded): {oldPath}"); if (!AdminPathContainsProgramFilesDotnet()) { - Console.WriteLine("Program Files dotnet path is not present in admin PATH. No changes needed."); LogMessage("No changes needed - dotnet path not found"); return 0; } - Console.WriteLine("Removing Program Files dotnet path from admin PATH..."); LogMessage("Removing dotnet paths from admin PATH"); - string newPath = RemoveProgramFilesDotnetFromAdminPath(); + string newPath = GetAdminPathWithProgramFilesDotnetRemoved(); LogMessage($"New PATH (unexpanded): {newPath}"); - Console.WriteLine("Writing updated admin PATH to registry..."); WriteAdminPath(newPath); LogMessage("PATH written to registry"); // Broadcast environment change BroadcastEnvironmentChange(); - LogMessage("Environment change broadcasted"); - Console.WriteLine("Successfully removed Program Files dotnet path from admin PATH."); LogMessage("RemoveDotnetFromAdminPath operation completed successfully"); return 0; } catch (Exception ex) { Console.Error.WriteLine($"Error: Failed to remove dotnet from admin PATH: {ex.Message}"); - LogMessage($"ERROR: {ex.Message}"); - LogMessage($"Stack trace: {ex.StackTrace}"); + LogMessage($"ERROR: {ex.ToString()}"); return 1; } } @@ -334,40 +328,33 @@ public int AddDotnetToAdminPath() try { LogMessage("Starting AddDotnetToAdminPath operation"); - Console.WriteLine("Reading current admin PATH from registry..."); string oldPath = ReadAdminPath(expand: false); LogMessage($"Old PATH (unexpanded): {oldPath}"); if (AdminPathContainsProgramFilesDotnet()) { - Console.WriteLine("Program Files dotnet path is already present in admin PATH. No changes needed."); LogMessage("No changes needed - dotnet path already exists"); return 0; } - Console.WriteLine("Adding Program Files dotnet path to admin PATH..."); LogMessage("Adding dotnet path to admin PATH"); string newPath = AddProgramFilesDotnetToPath(oldPath); LogMessage($"New PATH (unexpanded): {newPath}"); - Console.WriteLine("Writing updated admin PATH to registry..."); WriteAdminPath(newPath); LogMessage("PATH written to registry"); // Broadcast environment change BroadcastEnvironmentChange(); - LogMessage("Environment change broadcasted"); - Console.WriteLine("Successfully added Program Files dotnet path to admin PATH."); LogMessage("AddDotnetToAdminPath operation completed successfully"); return 0; } catch (Exception ex) { Console.Error.WriteLine($"Error: Failed to add dotnet to admin PATH: {ex.Message}"); - LogMessage($"ERROR: {ex.Message}"); - LogMessage($"Stack trace: {ex.StackTrace}"); + LogMessage($"ERROR: {ex.ToString()}"); return 1; } } @@ -388,56 +375,69 @@ private void BroadcastEnvironmentChange() 5000, out IntPtr result); - Console.WriteLine("Environment change notification broadcasted."); + LogMessage("Environment change notification broadcasted"); } catch (Exception ex) { - Console.Error.WriteLine($"Warning: Failed to broadcast environment change: {ex.Message}"); + LogMessage($"WARNING: Failed to broadcast environment change: {ex.ToString()}"); } } /// /// Starts an elevated process with the given arguments. /// - public static int StartElevatedProcess(string arguments) + /// True if the process succeeded (exit code 0), false if elevation was cancelled. + /// Thrown when the process cannot be started or returns a non-zero exit code. + public static bool StartElevatedProcess(string arguments) { - try + var processPath = Environment.ProcessPath; + if (string.IsNullOrEmpty(processPath)) { - var processPath = Environment.ProcessPath; - if (string.IsNullOrEmpty(processPath)) - { - Console.Error.WriteLine("Error: Unable to determine current process path."); - return 1; - } + throw new InvalidOperationException("Unable to determine current process path."); + } - var startInfo = new ProcessStartInfo - { - FileName = processPath, - Arguments = arguments, - Verb = "runas", // This triggers UAC elevation - UseShellExecute = true - }; + var startInfo = new ProcessStartInfo + { + FileName = processPath, + Arguments = arguments, + Verb = "runas", // This triggers UAC elevation + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + try + { using var process = Process.Start(startInfo); if (process == null) { - Console.Error.WriteLine("Error: Failed to start elevated process."); - return 1; + throw new InvalidOperationException("Failed to start elevated process."); } + string stdout = process.StandardOutput.ReadToEnd(); + string stderr = process.StandardError.ReadToEnd(); process.WaitForExit(); - return process.ExitCode; + + if (process.ExitCode != 0) + { + throw new InvalidOperationException( + $"Elevated process returned exit code {process.ExitCode}.{Environment.NewLine}" + + $"STDOUT: {stdout}{Environment.NewLine}" + + $"STDERR: {stderr}"); + } + + return true; } catch (System.ComponentModel.Win32Exception ex) { - // User cancelled UAC prompt - Console.Error.WriteLine($"Error: Elevation cancelled or failed: {ex.Message}"); - return 1; - } - catch (Exception ex) - { - Console.Error.WriteLine($"Error: Failed to start elevated process: {ex.Message}"); - return 1; + // User cancelled UAC prompt or elevation failed + // ERROR_CANCELLED = 1223 + if (ex.NativeErrorCode == 1223) + { + return false; + } + throw; } } } From 8aa862fbc01ec7a070b75ea150f529a59a3e71a1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Dec 2025 02:54:56 +0000 Subject: [PATCH 11/14] Replace IsElevated with Environment.IsPrivilegedProcess and add admin install root - Remove custom IsElevated() method and use Environment.IsPrivilegedProcess instead - Remove unused System.Security.Principal import - Add constants for install types (UserInstallType, AdminInstallType) in SetInstallRootCommandParser - Use AcceptOnlyFromAmong to validate install type arguments - Add SetAdminInstallRoot() method to restore admin installation: - Adds Program Files dotnet path back to admin PATH (with elevation if needed) - Removes user dotnet path from user PATH - Unsets user DOTNET_ROOT environment variable - Update SetInstallRootCommand.Execute() to handle both user and admin install types - All 18 tests pass Co-authored-by: dsplaisted <145043+dsplaisted@users.noreply.github.com> --- .../ElevatedAdminPathCommand.cs | 2 +- .../SetInstallRoot/SetInstallRootCommand.cs | 91 ++++++++++++++++++- .../SetInstallRootCommandParser.cs | 20 +++- src/Installer/dotnetup/WindowsPathHelper.cs | 11 +-- 4 files changed, 106 insertions(+), 18 deletions(-) diff --git a/src/Installer/dotnetup/Commands/ElevatedAdminPath/ElevatedAdminPathCommand.cs b/src/Installer/dotnetup/Commands/ElevatedAdminPath/ElevatedAdminPathCommand.cs index 2db2b09f4c33..33a0a8ad9dcc 100644 --- a/src/Installer/dotnetup/Commands/ElevatedAdminPath/ElevatedAdminPathCommand.cs +++ b/src/Installer/dotnetup/Commands/ElevatedAdminPath/ElevatedAdminPathCommand.cs @@ -26,7 +26,7 @@ public override int Execute() } // Check if running with elevated privileges - if (!WindowsPathHelper.IsElevated()) + if (!Environment.IsPrivilegedProcess) { Console.Error.WriteLine("Error: This operation requires administrator privileges. Please run from an elevated command prompt."); return 1; diff --git a/src/Installer/dotnetup/Commands/SetInstallRoot/SetInstallRootCommand.cs b/src/Installer/dotnetup/Commands/SetInstallRoot/SetInstallRootCommand.cs index 48259935fa73..2c051644e64f 100644 --- a/src/Installer/dotnetup/Commands/SetInstallRoot/SetInstallRootCommand.cs +++ b/src/Installer/dotnetup/Commands/SetInstallRoot/SetInstallRootCommand.cs @@ -22,7 +22,8 @@ public override int Execute() { return _installType.ToLowerInvariant() switch { - "user" => SetUserInstallRoot(), + SetInstallRootCommandParser.UserInstallType => SetUserInstallRoot(), + SetInstallRootCommandParser.AdminInstallType => SetAdminInstallRoot(), _ => throw new InvalidOperationException($"Unknown install type: {_installType}") }; } @@ -37,7 +38,7 @@ private void HandleWindowsAdminPath() { Console.WriteLine("Program Files dotnet path found in admin PATH. Removing it..."); - if (WindowsPathHelper.IsElevated()) + if (Environment.IsPrivilegedProcess) { // We're already elevated, modify the admin PATH directly Console.WriteLine("Running with elevated privileges. Modifying admin PATH..."); @@ -132,4 +133,90 @@ private int SetUserInstallRoot() return 1; } } + + private int SetAdminInstallRoot() + { + Console.WriteLine("Setting up admin install root..."); + + // On Windows, add Program Files dotnet path back to admin PATH and remove user settings + if (OperatingSystem.IsWindows()) + { + try + { + // Add Program Files dotnet path back to admin PATH + if (Environment.IsPrivilegedProcess) + { + // We're already elevated, modify the admin PATH directly + Console.WriteLine("Running with elevated privileges. Modifying admin PATH..."); + using var pathHelper = new WindowsPathHelper(); + pathHelper.AddDotnetToAdminPath(); + } + else + { + // Not elevated, shell out to elevated process + Console.WriteLine("Launching elevated process to modify admin PATH..."); + try + { + bool succeeded = WindowsPathHelper.StartElevatedProcess("elevatedadminpath adddotnet"); + if (!succeeded) + { + Console.Error.WriteLine("Warning: Elevation was cancelled. Admin PATH was not modified."); + return 1; + } + } + catch (InvalidOperationException ex) + { + Console.Error.WriteLine($"Error: Failed to modify admin PATH: {ex.Message}"); + Console.Error.WriteLine("You may need to manually add the Program Files dotnet path to the system PATH."); + return 1; + } + } + + // Get the user dotnet installation path + string userDotnetPath = _dotnetInstaller.GetDefaultDotnetInstallPath(); + + // Remove user dotnet path from user PATH + Console.WriteLine($"Removing {userDotnetPath} from user PATH..."); + var userPath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.User) ?? string.Empty; + var pathEntries = userPath.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries).ToList(); + + // Remove entries that match the user dotnet path + int removedCount = pathEntries.RemoveAll(entry => + Path.TrimEndingDirectorySeparator(entry).Equals( + Path.TrimEndingDirectorySeparator(userDotnetPath), + StringComparison.OrdinalIgnoreCase)); + + if (removedCount > 0) + { + var newUserPath = string.Join(Path.PathSeparator, pathEntries); + Environment.SetEnvironmentVariable("PATH", newUserPath, EnvironmentVariableTarget.User); + Console.WriteLine($"Successfully removed {userDotnetPath} from user PATH."); + } + else + { + Console.WriteLine($"User dotnet path was not found in user PATH."); + } + + // Unset user DOTNET_ROOT environment variable + Console.WriteLine("Unsetting user DOTNET_ROOT environment variable..."); + Environment.SetEnvironmentVariable("DOTNET_ROOT", null, EnvironmentVariableTarget.User); + Console.WriteLine("Successfully unset DOTNET_ROOT."); + + Console.WriteLine("Admin install root configured successfully."); + Console.WriteLine("Note: You may need to restart your terminal or application for the changes to take effect."); + + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error: Failed to configure admin install root: {ex.Message}"); + return 1; + } + } + else + { + Console.Error.WriteLine("Error: Admin install root is only supported on Windows."); + return 1; + } + } } diff --git a/src/Installer/dotnetup/Commands/SetInstallRoot/SetInstallRootCommandParser.cs b/src/Installer/dotnetup/Commands/SetInstallRoot/SetInstallRootCommandParser.cs index cae249391b93..8d1965cc0303 100644 --- a/src/Installer/dotnetup/Commands/SetInstallRoot/SetInstallRootCommandParser.cs +++ b/src/Installer/dotnetup/Commands/SetInstallRoot/SetInstallRootCommandParser.cs @@ -7,12 +7,22 @@ namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.SetInstallRoot; internal static class SetInstallRootCommandParser { - public static readonly Argument InstallTypeArgument = new("installtype") + public const string UserInstallType = "user"; + public const string AdminInstallType = "admin"; + + public static readonly Argument InstallTypeArgument = CreateInstallTypeArgument(); + + private static Argument CreateInstallTypeArgument() { - HelpName = "INSTALL_TYPE", - Description = "The type of installation root to set: 'user'", - Arity = ArgumentArity.ExactlyOne, - }; + var argument = new Argument("installtype") + { + HelpName = "INSTALL_TYPE", + Description = "The type of installation root to set: 'user' or 'admin'", + Arity = ArgumentArity.ExactlyOne, + }; + argument.AcceptOnlyFromAmong(UserInstallType, AdminInstallType); + return argument; + } private static readonly Command SetInstallRootCommand = ConstructCommand(); diff --git a/src/Installer/dotnetup/WindowsPathHelper.cs b/src/Installer/dotnetup/WindowsPathHelper.cs index f0c324f4e176..962f704baf1e 100644 --- a/src/Installer/dotnetup/WindowsPathHelper.cs +++ b/src/Installer/dotnetup/WindowsPathHelper.cs @@ -8,7 +8,6 @@ using System.Linq; using System.Runtime.InteropServices; using System.Runtime.Versioning; -using System.Security.Principal; using Microsoft.Win32; namespace Microsoft.DotNet.Tools.Bootstrapper; @@ -77,15 +76,7 @@ public void Dispose() } } - /// - /// Checks if the current process is running with elevated (administrator) privileges. - /// - public static bool IsElevated() - { - using var identity = WindowsIdentity.GetCurrent(); - var principal = new WindowsPrincipal(identity); - return principal.IsInRole(WindowsBuiltInRole.Administrator); - } + /// /// Reads the machine-wide PATH environment variable from the registry. From bd56b8918bddad3aadf90d65bfafa9cd5d1b3c4a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Dec 2025 17:01:16 +0000 Subject: [PATCH 12/14] Add overload to display dotnet paths found in admin PATH - Add AdminPathContainsProgramFilesDotnet(out List foundDotnetPaths) overload - The new overload returns the list of dotnet paths found in the admin PATH - Update HandleWindowsAdminPath to use the overload and display paths before removal - The original parameterless method now delegates to the new overload - All 18 tests pass Co-authored-by: dsplaisted <145043+dsplaisted@users.noreply.github.com> --- .../SetInstallRoot/SetInstallRootCommand.cs | 9 ++++++-- src/Installer/dotnetup/WindowsPathHelper.cs | 21 ++++++++++++++++++- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/Installer/dotnetup/Commands/SetInstallRoot/SetInstallRootCommand.cs b/src/Installer/dotnetup/Commands/SetInstallRoot/SetInstallRootCommand.cs index 2c051644e64f..a11205cefafc 100644 --- a/src/Installer/dotnetup/Commands/SetInstallRoot/SetInstallRootCommand.cs +++ b/src/Installer/dotnetup/Commands/SetInstallRoot/SetInstallRootCommand.cs @@ -34,9 +34,14 @@ private void HandleWindowsAdminPath() try { // Check if admin PATH needs to be changed - if (WindowsPathHelper.AdminPathContainsProgramFilesDotnet()) + if (WindowsPathHelper.AdminPathContainsProgramFilesDotnet(out var foundDotnetPaths)) { - Console.WriteLine("Program Files dotnet path found in admin PATH. Removing it..."); + Console.WriteLine("Program Files dotnet path(s) found in admin PATH:"); + foreach (var path in foundDotnetPaths) + { + Console.WriteLine($" - {path}"); + } + Console.WriteLine("Removing them..."); if (Environment.IsPrivilegedProcess) { diff --git a/src/Installer/dotnetup/WindowsPathHelper.cs b/src/Installer/dotnetup/WindowsPathHelper.cs index 962f704baf1e..0f685e35f6f4 100644 --- a/src/Installer/dotnetup/WindowsPathHelper.cs +++ b/src/Installer/dotnetup/WindowsPathHelper.cs @@ -260,12 +260,31 @@ public static string AddProgramFilesDotnetToPath(string path) /// Uses the expanded PATH for accurate detection. /// public static bool AdminPathContainsProgramFilesDotnet() + { + return AdminPathContainsProgramFilesDotnet(out _); + } + + /// + /// Checks if the admin PATH contains the Program Files dotnet path. + /// Uses the expanded PATH for accurate detection. + /// + /// The list of dotnet paths found in the admin PATH. + /// True if any dotnet path is found, false otherwise. + public static bool AdminPathContainsProgramFilesDotnet(out List foundDotnetPaths) { var adminPath = ReadAdminPath(expand: true); var pathEntries = SplitPath(adminPath); var programFilesDotnetPaths = GetProgramFilesDotnetPaths(); - return PathContainsDotnet(pathEntries, programFilesDotnetPaths); + foundDotnetPaths = new List(); + var indices = FindDotnetPathIndices(pathEntries, programFilesDotnetPaths); + + foreach (var index in indices) + { + foundDotnetPaths.Add(pathEntries[index]); + } + + return foundDotnetPaths.Count > 0; } /// From 532dcd0081114ea7a13ad460988cff79f31ed4ee Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Thu, 18 Dec 2025 12:52:46 -0500 Subject: [PATCH 13/14] Simplify output and exception handling --- .../SetInstallRoot/SetInstallRootCommand.cs | 84 +++++++++---------- 1 file changed, 38 insertions(+), 46 deletions(-) diff --git a/src/Installer/dotnetup/Commands/SetInstallRoot/SetInstallRootCommand.cs b/src/Installer/dotnetup/Commands/SetInstallRoot/SetInstallRootCommand.cs index a11205cefafc..8fdc935c9086 100644 --- a/src/Installer/dotnetup/Commands/SetInstallRoot/SetInstallRootCommand.cs +++ b/src/Installer/dotnetup/Commands/SetInstallRoot/SetInstallRootCommand.cs @@ -29,58 +29,45 @@ public override int Execute() } [SupportedOSPlatform("windows")] - private void HandleWindowsAdminPath() + private bool HandleWindowsAdminPath() { - try + // Check if admin PATH needs to be changed + if (WindowsPathHelper.AdminPathContainsProgramFilesDotnet(out var foundDotnetPaths)) { - // Check if admin PATH needs to be changed - if (WindowsPathHelper.AdminPathContainsProgramFilesDotnet(out var foundDotnetPaths)) + if (foundDotnetPaths.Count == 1) + { + Console.WriteLine($"Removing {foundDotnetPaths[0]} from system PATH."); + } + else { - Console.WriteLine("Program Files dotnet path(s) found in admin PATH:"); + Console.WriteLine("Removing the following dotnet paths from system PATH:"); foreach (var path in foundDotnetPaths) { Console.WriteLine($" - {path}"); } - Console.WriteLine("Removing them..."); + } - if (Environment.IsPrivilegedProcess) - { - // We're already elevated, modify the admin PATH directly - Console.WriteLine("Running with elevated privileges. Modifying admin PATH..."); - using var pathHelper = new WindowsPathHelper(); - pathHelper.RemoveDotnetFromAdminPath(); - } - else - { - // Not elevated, shell out to elevated process - Console.WriteLine("Launching elevated process to modify admin PATH..."); - try - { - bool succeeded = WindowsPathHelper.StartElevatedProcess("elevatedadminpath removedotnet"); - if (!succeeded) - { - Console.Error.WriteLine("Warning: Elevation was cancelled. Admin PATH was not modified."); - // Continue anyway - we can still set up the user PATH - } - } - catch (InvalidOperationException ex) - { - Console.Error.WriteLine($"Warning: Failed to modify admin PATH: {ex.Message}"); - Console.Error.WriteLine("You may need to manually remove the Program Files dotnet path from the system PATH."); - // Continue anyway - we can still set up the user PATH - } - } + if (Environment.IsPrivilegedProcess) + { + // We're already elevated, modify the admin PATH directly + using var pathHelper = new WindowsPathHelper(); + pathHelper.RemoveDotnetFromAdminPath(); } else { - Console.WriteLine("Admin PATH does not contain Program Files dotnet path. No changes needed."); + // Not elevated, shell out to elevated process + Console.WriteLine("Launching elevated process to modify system PATH..."); + + bool succeeded = WindowsPathHelper.StartElevatedProcess("elevatedadminpath removedotnet"); + if (!succeeded) + { + Console.Error.WriteLine("Warning: Elevation was cancelled. Admin PATH was not modified."); + return false; + } } - } - catch (Exception ex) - { - Console.Error.WriteLine($"Warning: Error while checking/modifying admin PATH: {ex.Message}"); - Console.Error.WriteLine("Continuing with user PATH setup..."); - } + } + + return true; } private int SetUserInstallRoot() @@ -90,15 +77,20 @@ private int SetUserInstallRoot() Console.WriteLine($"Setting up user install root at: {userDotnetPath}"); - // On Windows, check if we need to modify the admin PATH - if (OperatingSystem.IsWindows()) - { - HandleWindowsAdminPath(); - } // Add the user dotnet path to the user PATH try { + // On Windows, check if we need to modify the admin PATH + if (OperatingSystem.IsWindows()) + { + if (!HandleWindowsAdminPath()) + { + // UAC prompt was cancelled + return 1; + } + } + Console.WriteLine($"Adding {userDotnetPath} to user PATH..."); var userPath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.User) ?? string.Empty; @@ -134,7 +126,7 @@ private int SetUserInstallRoot() } catch (Exception ex) { - Console.Error.WriteLine($"Error: Failed to configure user install root: {ex.Message}"); + Console.Error.WriteLine($"Error: Failed to configure user install root: {ex.ToString()}"); return 1; } } From b18266222d836363c469cfec0cb6af4bae11adc1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Dec 2025 18:10:19 +0000 Subject: [PATCH 14/14] Refactor PATH operations to preserve unexpanded environment variables - Add ReadUserPath and WriteUserPath methods for user PATH registry access - Refactor AddProgramFilesDotnetToPath to take both unexpanded and expanded paths - Add new helper methods: AddPathEntry and RemovePathEntry for generic path manipulation - Update AddDotnetToAdminPath to read both expanded and unexpanded paths - Update SetUserInstallRoot to use WindowsPathHelper methods for registry operations on Windows - Update SetAdminInstallRoot to use WindowsPathHelper methods for user PATH removal - Add OS guards to ensure Windows-specific code only runs on Windows - Update tests to pass both parameters to AddProgramFilesDotnetToPath - All 18 tests pass Co-authored-by: dsplaisted <145043+dsplaisted@users.noreply.github.com> --- .../SetInstallRoot/SetInstallRootCommand.cs | 71 +++++++---- src/Installer/dotnetup/WindowsPathHelper.cs | 115 ++++++++++++++++-- test/dotnetup.Tests/WindowsPathHelperTests.cs | 10 +- 3 files changed, 156 insertions(+), 40 deletions(-) diff --git a/src/Installer/dotnetup/Commands/SetInstallRoot/SetInstallRootCommand.cs b/src/Installer/dotnetup/Commands/SetInstallRoot/SetInstallRootCommand.cs index 8fdc935c9086..c4f5576da214 100644 --- a/src/Installer/dotnetup/Commands/SetInstallRoot/SetInstallRootCommand.cs +++ b/src/Installer/dotnetup/Commands/SetInstallRoot/SetInstallRootCommand.cs @@ -93,26 +93,49 @@ private int SetUserInstallRoot() Console.WriteLine($"Adding {userDotnetPath} to user PATH..."); - var userPath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.User) ?? string.Empty; - var pathEntries = userPath.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries).ToList(); + if (OperatingSystem.IsWindows()) + { + // On Windows, read both expanded and unexpanded user PATH from registry + string unexpandedUserPath = WindowsPathHelper.ReadUserPath(expand: false); + string expandedUserPath = WindowsPathHelper.ReadUserPath(expand: true); - // Check if the user dotnet path is already in the user PATH - bool alreadyExists = pathEntries.Any(entry => - Path.TrimEndingDirectorySeparator(entry).Equals( - Path.TrimEndingDirectorySeparator(userDotnetPath), - StringComparison.OrdinalIgnoreCase)); + // Use the helper method to add the path while preserving unexpanded variables + string newUserPath = WindowsPathHelper.AddPathEntry(unexpandedUserPath, expandedUserPath, userDotnetPath); - if (!alreadyExists) - { - // Add to the beginning of PATH - pathEntries.Insert(0, userDotnetPath); - var newUserPath = string.Join(Path.PathSeparator, pathEntries); - Environment.SetEnvironmentVariable("PATH", newUserPath, EnvironmentVariableTarget.User); - Console.WriteLine($"Successfully added {userDotnetPath} to user PATH."); + if (newUserPath != unexpandedUserPath) + { + WindowsPathHelper.WriteUserPath(newUserPath); + Console.WriteLine($"Successfully added {userDotnetPath} to user PATH."); + } + else + { + Console.WriteLine($"User dotnet path is already in user PATH."); + } } else { - Console.WriteLine($"User dotnet path is already in user PATH."); + // On non-Windows, use Environment API which expands variables + var userPath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.User) ?? string.Empty; + var pathEntries = userPath.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries).ToList(); + + // Check if the user dotnet path is already in the user PATH + bool alreadyExists = pathEntries.Any(entry => + Path.TrimEndingDirectorySeparator(entry).Equals( + Path.TrimEndingDirectorySeparator(userDotnetPath), + StringComparison.OrdinalIgnoreCase)); + + if (!alreadyExists) + { + // Add to the beginning of PATH + pathEntries.Insert(0, userDotnetPath); + var newUserPath = string.Join(Path.PathSeparator, pathEntries); + Environment.SetEnvironmentVariable("PATH", newUserPath, EnvironmentVariableTarget.User); + Console.WriteLine($"Successfully added {userDotnetPath} to user PATH."); + } + else + { + Console.WriteLine($"User dotnet path is already in user PATH."); + } } // Set DOTNET_ROOT for user @@ -174,19 +197,17 @@ private int SetAdminInstallRoot() // Remove user dotnet path from user PATH Console.WriteLine($"Removing {userDotnetPath} from user PATH..."); - var userPath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.User) ?? string.Empty; - var pathEntries = userPath.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries).ToList(); - // Remove entries that match the user dotnet path - int removedCount = pathEntries.RemoveAll(entry => - Path.TrimEndingDirectorySeparator(entry).Equals( - Path.TrimEndingDirectorySeparator(userDotnetPath), - StringComparison.OrdinalIgnoreCase)); + // Read both expanded and unexpanded user PATH from registry to preserve environment variables + string unexpandedUserPath = WindowsPathHelper.ReadUserPath(expand: false); + string expandedUserPath = WindowsPathHelper.ReadUserPath(expand: true); - if (removedCount > 0) + // Use the helper method to remove the path while preserving unexpanded variables + string newUserPath = WindowsPathHelper.RemovePathEntry(unexpandedUserPath, expandedUserPath, userDotnetPath); + + if (newUserPath != unexpandedUserPath) { - var newUserPath = string.Join(Path.PathSeparator, pathEntries); - Environment.SetEnvironmentVariable("PATH", newUserPath, EnvironmentVariableTarget.User); + WindowsPathHelper.WriteUserPath(newUserPath); Console.WriteLine($"Successfully removed {userDotnetPath} from user PATH."); } else diff --git a/src/Installer/dotnetup/WindowsPathHelper.cs b/src/Installer/dotnetup/WindowsPathHelper.cs index 0f685e35f6f4..b828f50e2162 100644 --- a/src/Installer/dotnetup/WindowsPathHelper.cs +++ b/src/Installer/dotnetup/WindowsPathHelper.cs @@ -108,6 +108,36 @@ public static void WriteAdminPath(string path) key.SetValue(PathVariableName, path, RegistryValueKind.ExpandString); } + /// + /// Reads the user PATH environment variable from the registry. + /// + /// If true, expands environment variables in the PATH value. + public static string ReadUserPath(bool expand = false) + { + using var key = Registry.CurrentUser.OpenSubKey("Environment", writable: false); + if (key == null) + { + return string.Empty; + } + + var pathValue = key.GetValue(PathVariableName, null, expand ? RegistryValueOptions.None : RegistryValueOptions.DoNotExpandEnvironmentNames) as string; + return pathValue ?? string.Empty; + } + + /// + /// Writes the user PATH environment variable to the registry. + /// + public static void WriteUserPath(string path) + { + using var key = Registry.CurrentUser.OpenSubKey("Environment", writable: true); + if (key == null) + { + throw new InvalidOperationException("Unable to open registry key for user environment variables."); + } + + key.SetValue(PathVariableName, path, RegistryValueKind.ExpandString); + } + /// /// Gets the default Program Files dotnet installation path(s). /// @@ -230,29 +260,91 @@ public static string GetAdminPathWithProgramFilesDotnetRemoved() } /// - /// Adds the Program Files dotnet path to the given PATH string if it's not already present. + /// Adds the Program Files dotnet path to the given PATH strings if it's not already present. + /// Uses the expanded PATH for detection but modifies the unexpanded PATH to preserve environment variables. /// - public static string AddProgramFilesDotnetToPath(string path) + /// The unexpanded PATH string to modify. + /// The expanded PATH string to use for detection. + /// The modified unexpanded PATH string. + public static string AddProgramFilesDotnetToPath(string unexpandedPath, string expandedPath) { - var pathEntries = SplitPath(path); + var expandedEntries = SplitPath(expandedPath); var programFilesDotnetPaths = GetProgramFilesDotnetPaths(); // Get the primary Program Files dotnet path (non-x86) string primaryDotnetPath = programFilesDotnetPaths.FirstOrDefault() ?? string.Empty; if (string.IsNullOrEmpty(primaryDotnetPath)) { - return path; + return unexpandedPath; + } + + // Check if any Program Files dotnet path is already in the expanded PATH + bool alreadyExists = PathContainsDotnet(expandedEntries, programFilesDotnetPaths); + + if (!alreadyExists) + { + // Add to the beginning of the unexpanded PATH + var unexpandedEntries = SplitPath(unexpandedPath); + unexpandedEntries.Insert(0, primaryDotnetPath); + return string.Join(';', unexpandedEntries); } - // Check if any Program Files dotnet path is already in PATH - bool alreadyExists = PathContainsDotnet(pathEntries, programFilesDotnetPaths); + return unexpandedPath; + } + + /// + /// Adds a path entry to the given PATH strings if it's not already present. + /// Uses the expanded PATH for detection but modifies the unexpanded PATH to preserve environment variables. + /// + /// The unexpanded PATH string to modify. + /// The expanded PATH string to use for detection. + /// The path to add. + /// The modified unexpanded PATH string. + public static string AddPathEntry(string unexpandedPath, string expandedPath, string pathToAdd) + { + var expandedEntries = SplitPath(expandedPath); + + // Check if path is already in the expanded PATH + var normalizedPathToAdd = Path.TrimEndingDirectorySeparator(pathToAdd); + bool alreadyExists = expandedEntries.Any(entry => + Path.TrimEndingDirectorySeparator(entry).Equals(normalizedPathToAdd, StringComparison.OrdinalIgnoreCase)); if (!alreadyExists) { - pathEntries.Insert(0, primaryDotnetPath); + // Add to the beginning of the unexpanded PATH + var unexpandedEntries = SplitPath(unexpandedPath); + unexpandedEntries.Insert(0, pathToAdd); + return string.Join(';', unexpandedEntries); } - return string.Join(';', pathEntries); + return unexpandedPath; + } + + /// + /// Removes a specific path entry from the given PATH strings. + /// Uses the expanded PATH for detection but modifies the unexpanded PATH to preserve environment variables. + /// + /// The unexpanded PATH string to modify. + /// The expanded PATH string to use for detection. + /// The path to remove. + /// The modified unexpanded PATH string. + public static string RemovePathEntry(string unexpandedPath, string expandedPath, string pathToRemove) + { + var expandedEntries = SplitPath(expandedPath); + + // Find indices to remove using the expanded path + var normalizedPathToRemove = Path.TrimEndingDirectorySeparator(pathToRemove); + var indicesToRemove = new List(); + for (int i = 0; i < expandedEntries.Count; i++) + { + if (Path.TrimEndingDirectorySeparator(expandedEntries[i]).Equals(normalizedPathToRemove, StringComparison.OrdinalIgnoreCase)) + { + indicesToRemove.Add(i); + } + } + + // Remove those indices from the unexpanded path + return RemovePathEntriesByIndices(unexpandedPath, indicesToRemove); } /// @@ -339,8 +431,9 @@ public int AddDotnetToAdminPath() { LogMessage("Starting AddDotnetToAdminPath operation"); - string oldPath = ReadAdminPath(expand: false); - LogMessage($"Old PATH (unexpanded): {oldPath}"); + string unexpandedPath = ReadAdminPath(expand: false); + string expandedPath = ReadAdminPath(expand: true); + LogMessage($"Old PATH (unexpanded): {unexpandedPath}"); if (AdminPathContainsProgramFilesDotnet()) { @@ -349,7 +442,7 @@ public int AddDotnetToAdminPath() } LogMessage("Adding dotnet path to admin PATH"); - string newPath = AddProgramFilesDotnetToPath(oldPath); + string newPath = AddProgramFilesDotnetToPath(unexpandedPath, expandedPath); LogMessage($"New PATH (unexpanded): {newPath}"); WriteAdminPath(newPath); diff --git a/test/dotnetup.Tests/WindowsPathHelperTests.cs b/test/dotnetup.Tests/WindowsPathHelperTests.cs index 67f5b7a62861..a1aef91b3619 100644 --- a/test/dotnetup.Tests/WindowsPathHelperTests.cs +++ b/test/dotnetup.Tests/WindowsPathHelperTests.cs @@ -59,10 +59,11 @@ public void AddProgramFilesDotnetToPath_AddsCorrectPath() } // Arrange - string path = "C:\\SomeOtherPath;C:\\AnotherPath"; + string unexpandedPath = "C:\\SomeOtherPath;C:\\AnotherPath"; + string expandedPath = unexpandedPath; // No environment variables to expand in test // Act - string result = WindowsPathHelper.AddProgramFilesDotnetToPath(path); + string result = WindowsPathHelper.AddProgramFilesDotnetToPath(unexpandedPath, expandedPath); // Assert string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); @@ -84,10 +85,11 @@ public void AddProgramFilesDotnetToPath_DoesNotAddDuplicatePath() // Arrange string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); string dotnetPath = Path.Combine(programFiles, "dotnet"); - string path = $"C:\\SomeOtherPath;{dotnetPath};C:\\AnotherPath"; + string unexpandedPath = $"C:\\SomeOtherPath;{dotnetPath};C:\\AnotherPath"; + string expandedPath = unexpandedPath; // No environment variables to expand in test // Act - string result = WindowsPathHelper.AddProgramFilesDotnetToPath(path); + string result = WindowsPathHelper.AddProgramFilesDotnetToPath(unexpandedPath, expandedPath); // Assert // Count occurrences of dotnetPath in result