diff --git a/src/Installer/dotnetup/Commands/ElevatedAdminPath/ElevatedAdminPathCommand.cs b/src/Installer/dotnetup/Commands/ElevatedAdminPath/ElevatedAdminPathCommand.cs new file mode 100644 index 000000000000..33a0a8ad9dcc --- /dev/null +++ b/src/Installer/dotnetup/Commands/ElevatedAdminPath/ElevatedAdminPathCommand.cs @@ -0,0 +1,56 @@ +// 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 (!Environment.IsPrivilegedProcess) + { + 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() + { + using var pathHelper = new WindowsPathHelper(); + return pathHelper.RemoveDotnetFromAdminPath(); + } + + [SupportedOSPlatform("windows")] + private int AddDotnet() + { + using var pathHelper = new WindowsPathHelper(); + return pathHelper.AddDotnetToAdminPath(); + } +} diff --git a/src/Installer/dotnetup/Commands/ElevatedAdminPath/ElevatedAdminPathCommandParser.cs b/src/Installer/dotnetup/Commands/ElevatedAdminPath/ElevatedAdminPathCommandParser.cs new file mode 100644 index 000000000000..7b8f406e95e8 --- /dev/null +++ b/src/Installer/dotnetup/Commands/ElevatedAdminPath/ElevatedAdminPathCommandParser.cs @@ -0,0 +1,35 @@ +// 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.Hidden = true; + + 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..c4f5576da214 --- /dev/null +++ b/src/Installer/dotnetup/Commands/SetInstallRoot/SetInstallRootCommand.cs @@ -0,0 +1,240 @@ +// 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 + { + SetInstallRootCommandParser.UserInstallType => SetUserInstallRoot(), + SetInstallRootCommandParser.AdminInstallType => SetAdminInstallRoot(), + _ => throw new InvalidOperationException($"Unknown install type: {_installType}") + }; + } + + [SupportedOSPlatform("windows")] + private bool HandleWindowsAdminPath() + { + // 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("Removing the following dotnet paths from system PATH:"); + foreach (var path in foundDotnetPaths) + { + Console.WriteLine($" - {path}"); + } + } + + if (Environment.IsPrivilegedProcess) + { + // We're already elevated, modify the admin PATH directly + using var pathHelper = new WindowsPathHelper(); + pathHelper.RemoveDotnetFromAdminPath(); + } + else + { + // 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; + } + } + } + + return true; + } + + private int SetUserInstallRoot() + { + // Get the default user dotnet installation path + string userDotnetPath = _dotnetInstaller.GetDefaultDotnetInstallPath(); + + Console.WriteLine($"Setting up user install root at: {userDotnetPath}"); + + + // 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..."); + + 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); + + // Use the helper method to add the path while preserving unexpanded variables + string newUserPath = WindowsPathHelper.AddPathEntry(unexpandedUserPath, expandedUserPath, userDotnetPath); + + 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 + { + // 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 + 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.ToString()}"); + 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..."); + + // 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); + + // Use the helper method to remove the path while preserving unexpanded variables + string newUserPath = WindowsPathHelper.RemovePathEntry(unexpandedUserPath, expandedUserPath, userDotnetPath); + + if (newUserPath != unexpandedUserPath) + { + WindowsPathHelper.WriteUserPath(newUserPath); + 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 new file mode 100644 index 000000000000..8d1965cc0303 --- /dev/null +++ b/src/Installer/dotnetup/Commands/SetInstallRoot/SetInstallRootCommandParser.cs @@ -0,0 +1,44 @@ +// 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 const string UserInstallType = "user"; + public const string AdminInstallType = "admin"; + + public static readonly Argument InstallTypeArgument = CreateInstallTypeArgument(); + + private static Argument CreateInstallTypeArgument() + { + 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(); + + 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..b828f50e2162 --- /dev/null +++ b/src/Installer/dotnetup/WindowsPathHelper.cs @@ -0,0 +1,546 @@ +// 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 Microsoft.Win32; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +/// +/// Helper class for Windows-specific PATH management operations. +/// +[SupportedOSPlatform("windows")] +internal sealed class WindowsPathHelper : IDisposable +{ + 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; + + 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, + int Msg, + IntPtr wParam, + string lParam, + int fuFlags, + 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) + { + throw new InvalidOperationException("Failed to create log file for PATH changes.", ex); + } + } + + /// + /// Logs a message to the log file. + /// + private void LogMessage(string message) + { + _logWriter?.WriteLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {message}"); + } + + public void Dispose() + { + if (!_disposed) + { + LogMessage($"=== WindowsPathHelper session ended at {DateTime.Now:yyyy-MM-dd HH:mm:ss} ==="); + _logWriter?.Dispose(); + _disposed = true; + } + } + + + + /// + /// Reads the machine-wide PATH environment variable from the registry. + /// + /// 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) + { + throw new InvalidOperationException("Unable to open registry key for environment variables."); + } + + var pathValue = key.GetValue(PathVariableName, null, expand ? RegistryValueOptions.None : RegistryValueOptions.DoNotExpandEnvironmentNames) 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); + } + + /// + /// 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). + /// + 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; + } + + /// + /// Splits a PATH string into entries. + /// + 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 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 modified unexpanded PATH string. + public static string RemoveProgramFilesDotnetFromPath(string unexpandedPath, string expandedPath) + { + // 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); + } + + /// + /// 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 GetAdminPathWithProgramFilesDotnetRemoved() + { + // Read both expanded and unexpanded versions + string expandedPath = ReadAdminPath(expand: true); + string unexpandedPath = ReadAdminPath(expand: false); + + return RemoveProgramFilesDotnetFromPath(unexpandedPath, expandedPath); + } + + /// + /// 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. + /// + /// 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 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 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); + } + + 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) + { + // Add to the beginning of the unexpanded PATH + var unexpandedEntries = SplitPath(unexpandedPath); + unexpandedEntries.Insert(0, pathToAdd); + return string.Join(';', unexpandedEntries); + } + + 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); + } + + /// + /// Checks if the admin PATH contains the Program Files dotnet 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(); + + foundDotnetPaths = new List(); + var indices = FindDotnetPathIndices(pathEntries, programFilesDotnetPaths); + + foreach (var index in indices) + { + foundDotnetPaths.Add(pathEntries[index]); + } + + return foundDotnetPaths.Count > 0; + } + + /// + /// Removes the Program Files dotnet path from the admin PATH. + /// This is the main orchestrating method that should be called by commands. + /// + /// 0 on success, 1 on failure. + public int RemoveDotnetFromAdminPath() + { + try + { + LogMessage("Starting RemoveDotnetFromAdminPath operation"); + + string oldPath = ReadAdminPath(expand: false); + LogMessage($"Old PATH (unexpanded): {oldPath}"); + + if (!AdminPathContainsProgramFilesDotnet()) + { + LogMessage("No changes needed - dotnet path not found"); + return 0; + } + + LogMessage("Removing dotnet paths from admin PATH"); + string newPath = GetAdminPathWithProgramFilesDotnetRemoved(); + LogMessage($"New PATH (unexpanded): {newPath}"); + + WriteAdminPath(newPath); + LogMessage("PATH written to registry"); + + // Broadcast environment change + BroadcastEnvironmentChange(); + + 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.ToString()}"); + 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"); + + string unexpandedPath = ReadAdminPath(expand: false); + string expandedPath = ReadAdminPath(expand: true); + LogMessage($"Old PATH (unexpanded): {unexpandedPath}"); + + if (AdminPathContainsProgramFilesDotnet()) + { + LogMessage("No changes needed - dotnet path already exists"); + return 0; + } + + LogMessage("Adding dotnet path to admin PATH"); + string newPath = AddProgramFilesDotnetToPath(unexpandedPath, expandedPath); + LogMessage($"New PATH (unexpanded): {newPath}"); + + WriteAdminPath(newPath); + LogMessage("PATH written to registry"); + + // Broadcast environment change + BroadcastEnvironmentChange(); + + 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.ToString()}"); + return 1; + } + } + + /// + /// Broadcasts a WM_SETTINGCHANGE message to notify other applications that the environment has changed. + /// + private void BroadcastEnvironmentChange() + { + try + { + SendMessageTimeout( + new IntPtr(HWND_BROADCAST), + WM_SETTINGCHANGE, + IntPtr.Zero, + "Environment", + SMTO_ABORTIFHUNG, + 5000, + out IntPtr result); + + LogMessage("Environment change notification broadcasted"); + } + catch (Exception ex) + { + LogMessage($"WARNING: Failed to broadcast environment change: {ex.ToString()}"); + } + } + + /// + /// Starts an elevated process with the given 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) + { + var processPath = Environment.ProcessPath; + if (string.IsNullOrEmpty(processPath)) + { + throw new InvalidOperationException("Unable to determine current process path."); + } + + 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) + { + throw new InvalidOperationException("Failed to start elevated process."); + } + + string stdout = process.StandardOutput.ReadToEnd(); + string stderr = process.StandardError.ReadToEnd(); + process.WaitForExit(); + + 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 or elevation failed + // ERROR_CANCELLED = 1223 + if (ex.NativeErrorCode == 1223) + { + return false; + } + throw; + } + } +} 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..a1aef91b3619 --- /dev/null +++ b/test/dotnetup.Tests/WindowsPathHelperTests.cs @@ -0,0 +1,262 @@ +// 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 - pass the same path for both since no environment variables are used + string result = WindowsPathHelper.RemoveProgramFilesDotnetFromPath(path, 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 - pass the same path for both since no environment variables are used + string result = WindowsPathHelper.RemoveProgramFilesDotnetFromPath(path, 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 unexpandedPath = "C:\\SomeOtherPath;C:\\AnotherPath"; + string expandedPath = unexpandedPath; // No environment variables to expand in test + + // Act + string result = WindowsPathHelper.AddProgramFilesDotnetToPath(unexpandedPath, expandedPath); + + // 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 unexpandedPath = $"C:\\SomeOtherPath;{dotnetPath};C:\\AnotherPath"; + string expandedPath = unexpandedPath; // No environment variables to expand in test + + // Act + string result = WindowsPathHelper.AddProgramFilesDotnetToPath(unexpandedPath, expandedPath); + + // 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")); + } + + [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%"); + } +}