diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveDownloader.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveDownloader.cs index e8ba12aaed89..25ce3f1bdc6c 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveDownloader.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveDownloader.cs @@ -28,6 +28,7 @@ internal class DotnetArchiveDownloader : IDisposable private readonly HttpClient _httpClient; private readonly bool _shouldDisposeHttpClient; private ReleaseManifest _releaseManifest; + private readonly DownloadCache _downloadCache; public DotnetArchiveDownloader() : this(new ReleaseManifest()) @@ -37,6 +38,7 @@ public DotnetArchiveDownloader() public DotnetArchiveDownloader(ReleaseManifest releaseManifest, HttpClient? httpClient = null) { _releaseManifest = releaseManifest ?? throw new ArgumentNullException(nameof(releaseManifest)); + _downloadCache = new DownloadCache(); if (httpClient == null) { _httpClient = CreateDefaultHttpClient(); @@ -192,6 +194,7 @@ void DownloadArchive(string downloadUrl, string destinationPath, IProgress /// Downloads the archive for the specified installation and verifies its hash. + /// Checks the download cache first to avoid re-downloading. /// /// The .NET installation details /// The local path to save the downloaded file @@ -212,9 +215,43 @@ public void DownloadArchiveWithVerification(DotnetInstallRequest installRequest, throw new ArgumentException($"{nameof(downloadUrl)} cannot be null or empty"); } + // Check the cache first + string? cachedFilePath = _downloadCache.GetCachedFilePath(downloadUrl); + if (cachedFilePath != null) + { + try + { + // Verify the cached file's hash + VerifyFileHash(cachedFilePath, expectedHash); + + // Copy from cache to destination + File.Copy(cachedFilePath, destinationPath, overwrite: true); + + // Report 100% progress immediately since we're using cache + progress?.Report(new DownloadProgress(100, 100)); + return; + } + catch + { + // If cached file is corrupted, fall through to download + } + } + + // Download the file if not in cache or cache is invalid DownloadArchive(downloadUrl, destinationPath, progress); + // Verify the downloaded file VerifyFileHash(destinationPath, expectedHash); + + // Add the verified file to the cache + try + { + _downloadCache.AddToCache(downloadUrl, destinationPath); + } + catch + { + // Ignore errors adding to cache - it's not critical + } } diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/DownloadCache.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/DownloadCache.cs new file mode 100644 index 000000000000..5ea7682b30a3 --- /dev/null +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/DownloadCache.cs @@ -0,0 +1,146 @@ +// 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.IO; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Dotnet.Installation.Internal; + +/// +/// JSON serialization context for the download cache index. +/// +[JsonSourceGenerationOptions(WriteIndented = true)] +[JsonSerializable(typeof(Dictionary))] +internal partial class DownloadCacheJsonContext : JsonSerializerContext +{ +} + +/// +/// Manages a cache of downloaded .NET archives to avoid re-downloading files. +/// +internal class DownloadCache +{ + private static readonly string CacheDirectory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "dotnetup", + "downloadcache"); + + private static readonly string CacheIndexPath = Path.Combine(CacheDirectory, "cache-index.json"); + + private Dictionary _cacheIndex; + + public DownloadCache() + { + _cacheIndex = LoadCacheIndex(); + } + + /// + /// Gets the path to a cached file for the given URL, if it exists. + /// + /// The URL that was used to download the file + /// The path to the cached file, or null if not found + public string? GetCachedFilePath(string downloadUrl) + { + if (_cacheIndex.TryGetValue(downloadUrl, out string? fileName)) + { + string filePath = Path.Combine(CacheDirectory, fileName); + if (File.Exists(filePath)) + { + return filePath; + } + // File was deleted, remove from index + _cacheIndex.Remove(downloadUrl); + SaveCacheIndex(); + } + return null; + } + + /// + /// Adds a file to the cache. + /// + /// The URL the file was downloaded from + /// The path to the file to cache + public void AddToCache(string downloadUrl, string sourceFilePath) + { + // Ensure cache directory exists + Directory.CreateDirectory(CacheDirectory); + + // Use the filename from the download URL + string fileName = GetFileNameFromUrl(downloadUrl); + string cachedFilePath = Path.Combine(CacheDirectory, fileName); + + // Skip if this filename is already cached for a different URL + // (collision case - we'll download the right file when needed and hash check will catch it) + if (_cacheIndex.Values.Contains(fileName) && !_cacheIndex.ContainsKey(downloadUrl)) + { + return; + } + + // Copy the file to the cache + File.Copy(sourceFilePath, cachedFilePath, overwrite: true); + + // Update the index + _cacheIndex[downloadUrl] = fileName; + SaveCacheIndex(); + } + + /// + /// Extracts the filename from a download URL. + /// + private string GetFileNameFromUrl(string downloadUrl) + { + Uri uri = new Uri(downloadUrl); + string fileName = Path.GetFileName(uri.LocalPath); + + // Fallback to a default name if we can't extract a filename + if (string.IsNullOrEmpty(fileName)) + { + fileName = "download.dat"; + } + + return fileName; + } + + /// + /// Loads the cache index from disk. + /// + private Dictionary LoadCacheIndex() + { + if (!File.Exists(CacheIndexPath)) + { + return new Dictionary(); + } + + try + { + string json = File.ReadAllText(CacheIndexPath); + var index = JsonSerializer.Deserialize(json, DownloadCacheJsonContext.Default.DictionaryStringString); + return index ?? new Dictionary(); + } + catch + { + // If the index is corrupted, start fresh + return new Dictionary(); + } + } + + /// + /// Saves the cache index to disk. + /// + private void SaveCacheIndex() + { + try + { + Directory.CreateDirectory(CacheDirectory); + string json = JsonSerializer.Serialize(_cacheIndex, DownloadCacheJsonContext.Default.DictionaryStringString); + File.WriteAllText(CacheIndexPath, json); + } + catch + { + // Ignore errors saving the cache index - it's not critical + } + } +} diff --git a/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs b/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs new file mode 100644 index 000000000000..e9c5622d01c5 --- /dev/null +++ b/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs @@ -0,0 +1,112 @@ +// 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.DefaultInstall; + +internal class DefaultInstallCommand : CommandBase +{ + private readonly string _installType; + private readonly InstallRootManager _installRootManager; + + public DefaultInstallCommand(ParseResult result, IDotnetInstallManager? dotnetInstaller = null) : base(result) + { + _installType = result.GetValue(DefaultInstallCommandParser.InstallTypeArgument)!; + _installRootManager = new InstallRootManager(dotnetInstaller); + } + + public override int Execute() + { + return _installType.ToLowerInvariant() switch + { + DefaultInstallCommandParser.UserInstallType => SetUserInstallRoot(), + DefaultInstallCommandParser.AdminInstallType => SetAdminInstallRoot(), + _ => throw new InvalidOperationException($"Unknown install type: {_installType}") + }; + } + + private int SetUserInstallRoot() + { + try + { + if (OperatingSystem.IsWindows()) + { + var changes = _installRootManager.GetUserInstallRootChanges(); + + if (!changes.NeedsChange()) + { + Console.WriteLine($"User install root already configured for {changes.UserDotnetPath}"); + return 0; + } + + Console.WriteLine($"Setting up user install root at: {changes.UserDotnetPath}"); + + bool succeeded = _installRootManager.ApplyUserInstallRoot( + changes, + Console.WriteLine, + Console.Error.WriteLine); + + if (!succeeded) + { + // UAC prompt was cancelled + return 1; + } + + Console.WriteLine("Succeeded. NOTE: You may need to restart your terminal or application for the changes to take effect."); + return 0; + } + else + { + Console.Error.WriteLine("Error: Non-Windows platforms not yet supported"); + return 1; + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error: Failed to configure user install root: {ex.ToString()}"); + return 1; + } + } + + private int SetAdminInstallRoot() + { + try + { + if (OperatingSystem.IsWindows()) + { + var changes = _installRootManager.GetAdminInstallRootChanges(); + + if (!changes.NeedsChange()) + { + Console.WriteLine("Admin install root already configured."); + return 0; + } + + bool succeeded = _installRootManager.ApplyAdminInstallRoot( + changes, + Console.WriteLine, + Console.Error.WriteLine); + + if (!succeeded) + { + // Elevation was cancelled + return 1; + } + + Console.WriteLine("Succeeded. NOTE: You may need to restart your terminal or application for the changes to take effect."); + return 0; + } + else + { + Console.Error.WriteLine("Error: Admin install root is only supported on Windows."); + return 1; + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error: Failed to configure admin install root: {ex.ToString()}"); + return 1; + } + } +} diff --git a/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommandParser.cs b/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommandParser.cs new file mode 100644 index 000000000000..386bec1de272 --- /dev/null +++ b/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommandParser.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.DefaultInstall; + +internal static class DefaultInstallCommandParser +{ + 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: '{UserInstallType}' or '{AdminInstallType}'", + Arity = ArgumentArity.ExactlyOne, + }; + argument.AcceptOnlyFromAmong(UserInstallType, AdminInstallType); + return argument; + } + + private static readonly Command DefaultInstallCommand = ConstructCommand(); + + public static Command GetCommand() + { + return DefaultInstallCommand; + } + + private static Command ConstructCommand() + { + Command command = new("defaultinstall", "Sets the default dotnet installation"); + + command.Arguments.Add(InstallTypeArgument); + + command.SetAction(parseResult => new DefaultInstallCommand(parseResult).Execute()); + + return command; + } +} diff --git a/src/Installer/dotnetup/Commands/ElevatedAdminPath/ElevatedAdminPathCommand.cs b/src/Installer/dotnetup/Commands/ElevatedAdminPath/ElevatedAdminPathCommand.cs new file mode 100644 index 000000000000..c55fe7aae5a1 --- /dev/null +++ b/src/Installer/dotnetup/Commands/ElevatedAdminPath/ElevatedAdminPathCommand.cs @@ -0,0 +1,72 @@ +// 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; + private readonly string _outputFile; + + public ElevatedAdminPathCommand(ParseResult result) : base(result) + { + _operation = result.GetValue(ElevatedAdminPathCommandParser.OperationArgument)!; + _outputFile = result.GetValue(ElevatedAdminPathCommandParser.OutputFile)!; + } + + void Log(string message) + { + Console.WriteLine(message); + File.AppendAllText(_outputFile, message + Environment.NewLine); + } + + public override int Execute() + { + // This command only works on Windows + if (!OperatingSystem.IsWindows()) + { + Log("Error: The elevatedadminpath command is only supported on Windows."); + return 1; + } + + // Check if running with elevated privileges + if (!Environment.IsPrivilegedProcess) + { + Log("Error: This operation requires administrator privileges. Please run from an elevated command prompt."); + return 1; + } + + try + { + return _operation.ToLowerInvariant() switch + { + "removedotnet" => RemoveDotnet(), + "adddotnet" => AddDotnet(), + _ => throw new InvalidOperationException($"Unknown operation: {_operation}") + }; + } + catch (Exception ex) + { + Log($"Error: {ex.ToString()}"); + return 1; + } + } + + [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..747771dd0fbd --- /dev/null +++ b/src/Installer/dotnetup/Commands/ElevatedAdminPath/ElevatedAdminPathCommandParser.cs @@ -0,0 +1,43 @@ +// 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, + }; + + public static readonly Argument OutputFile = new("outputfile") + { + HelpName = "OUTPUT_FILE", + Description = "A file where any output that should be displayed to the user should be written.", + 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.Arguments.Add(OutputFile); + + command.SetAction(parseResult => new ElevatedAdminPathCommand(parseResult).Execute()); + + return command; + } +} diff --git a/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommand.cs index dfe465f258a1..b8b11bdd78b5 100644 --- a/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -155,7 +155,7 @@ public override int Execute() { SpectreAnsiConsole.WriteLine($"You have an existing admin install of .NET in {currentDotnetInstallRoot.Path}. We can configure your system to use the new install of .NET " + $"in {resolvedInstallPath} instead. This would mean that the admin install of .NET would no longer be accessible from the PATH or from Visual Studio."); - SpectreAnsiConsole.WriteLine("You can change this later with the \"dotnet defaultinstall\" command."); + SpectreAnsiConsole.WriteLine("You can change this later with the \"dotnetup defaultinstall\" command."); resolvedSetDefaultInstall = SpectreAnsiConsole.Confirm( $"Do you want to set the user install path ({resolvedInstallPath}) as the default dotnet install? This will update the PATH and DOTNET_ROOT environment variables.", defaultValue: true); diff --git a/src/Installer/dotnetup/InstallRootManager.cs b/src/Installer/dotnetup/InstallRootManager.cs new file mode 100644 index 000000000000..5351a0972fb0 --- /dev/null +++ b/src/Installer/dotnetup/InstallRootManager.cs @@ -0,0 +1,256 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.Versioning; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +/// +/// Manages the dotnet installation root configuration, including switching between user and admin installations. +/// +internal class InstallRootManager +{ + private readonly IDotnetInstallManager _dotnetInstaller; + + public InstallRootManager(IDotnetInstallManager? dotnetInstaller = null) + { + _dotnetInstaller = dotnetInstaller ?? new DotnetInstallManager(); + } + + /// + /// Gets the changes needed to configure user install root. + /// + public UserInstallRootChanges GetUserInstallRootChanges() + { + if (!OperatingSystem.IsWindows()) + { + throw new PlatformNotSupportedException("User install root configuration is only supported on Windows."); + } + + string userDotnetPath = _dotnetInstaller.GetDefaultDotnetInstallPath(); + bool needToRemoveAdminPath = WindowsPathHelper.AdminPathContainsProgramFilesDotnet(out var foundDotnetPaths); + + // 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); + bool needToAddToUserPath = newUserPath != unexpandedUserPath; + + var existingDotnetRoot = Environment.GetEnvironmentVariable("DOTNET_ROOT", EnvironmentVariableTarget.User); + bool needToSetDotnetRoot = !string.Equals(userDotnetPath, existingDotnetRoot, StringComparison.OrdinalIgnoreCase); + + return new UserInstallRootChanges( + userDotnetPath, + needToRemoveAdminPath, + needToAddToUserPath, + needToSetDotnetRoot, + newUserPath, + foundDotnetPaths); + } + + /// + /// Gets the changes needed to configure admin install root. + /// + public AdminInstallRootChanges GetAdminInstallRootChanges() + { + if (!OperatingSystem.IsWindows()) + { + throw new PlatformNotSupportedException("Admin install root configuration is only supported on Windows."); + } + + var programFilesDotnetPaths = WindowsPathHelper.GetProgramFilesDotnetPaths(); + bool needToModifyAdminPath = !WindowsPathHelper.SplitPath(WindowsPathHelper.ReadAdminPath(expand: true)) + .Contains(programFilesDotnetPaths.First(), StringComparer.OrdinalIgnoreCase); + + // Get the user dotnet installation path + string userDotnetPath = _dotnetInstaller.GetDefaultDotnetInstallPath(); + + // 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.RemovePathEntries(unexpandedUserPath, expandedUserPath, [userDotnetPath]); + bool needToModifyUserPath = newUserPath != unexpandedUserPath; + + bool needToUnsetDotnetRoot = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("DOTNET_ROOT", EnvironmentVariableTarget.User)); + + return new AdminInstallRootChanges( + programFilesDotnetPaths.First(), + needToModifyAdminPath, + needToModifyUserPath, + needToUnsetDotnetRoot, + userDotnetPath, + newUserPath); + } + + /// + /// Applies the user install root configuration. + /// Returns true if successful, false if elevation was cancelled. + /// + [SupportedOSPlatform("windows")] + public bool ApplyUserInstallRoot(UserInstallRootChanges changes, Action writeOutput, Action writeError) + { + if (changes.NeedsRemoveAdminPath) + { + if (!RemoveAdminPathIfNeeded(changes.FoundAdminDotnetPaths!, writeOutput, writeError)) + { + return false; // Elevation was cancelled + } + } + + if (changes.NeedsAddToUserPath) + { + writeOutput($"Adding {changes.UserDotnetPath} to user PATH."); + WindowsPathHelper.WriteUserPath(changes.NewUserPath!); + } + + if (changes.NeedsSetDotnetRoot) + { + writeOutput($"Setting DOTNET_ROOT to {changes.UserDotnetPath}"); + Environment.SetEnvironmentVariable("DOTNET_ROOT", changes.UserDotnetPath, EnvironmentVariableTarget.User); + } + + return true; + } + + /// + /// Applies the admin install root configuration. + /// Returns true if successful, false if elevation was cancelled. + /// + [SupportedOSPlatform("windows")] + public bool ApplyAdminInstallRoot(AdminInstallRootChanges changes, Action writeOutput, Action writeError) + { + if (changes.NeedsModifyAdminPath) + { + if (!AddAdminPathIfNeeded(changes.ProgramFilesDotnetPath, writeOutput, writeError)) + { + return false; // Elevation was cancelled + } + } + + if (changes.NeedsModifyUserPath) + { + writeOutput($"Removing {changes.UserDotnetPath} from user PATH."); + WindowsPathHelper.WriteUserPath(changes.NewUserPath!); + } + + if (changes.NeedsUnsetDotnetRoot) + { + writeOutput("Unsetting DOTNET_ROOT environment variable."); + Environment.SetEnvironmentVariable("DOTNET_ROOT", null, EnvironmentVariableTarget.User); + } + + return true; + } + + [SupportedOSPlatform("windows")] + private bool RemoveAdminPathIfNeeded(List foundDotnetPaths, Action writeOutput, Action writeError) + { + if (Environment.IsPrivilegedProcess) + { + if (foundDotnetPaths.Count == 1) + { + writeOutput($"Removing {foundDotnetPaths[0]} from system PATH."); + } + else + { + writeOutput("Removing the following dotnet paths from system PATH:"); + foreach (var path in foundDotnetPaths) + { + writeOutput($" - {path}"); + } + } + + // We're already elevated, modify the admin PATH directly + using var pathHelper = new WindowsPathHelper(); + pathHelper.RemoveDotnetFromAdminPath(); + } + else + { + // Not elevated, shell out to elevated process + if (foundDotnetPaths.Count == 1) + { + writeOutput($"Launching elevated process to remove {foundDotnetPaths[0]} from system PATH."); + } + else + { + writeOutput("Launching elevated process to remove the following dotnet paths from system PATH:"); + foreach (var path in foundDotnetPaths) + { + writeOutput($" - {path}"); + } + } + + bool succeeded = WindowsPathHelper.StartElevatedProcess("removedotnet"); + if (!succeeded) + { + writeError("Warning: Elevation was cancelled. System PATH was not modified."); + return false; + } + } + + return true; + } + + [SupportedOSPlatform("windows")] + private bool AddAdminPathIfNeeded(string programFilesDotnetPath, Action writeOutput, Action writeError) + { + if (Environment.IsPrivilegedProcess) + { + // We're already elevated, modify the admin PATH directly + writeOutput($"Adding {programFilesDotnetPath} to system PATH."); + using var pathHelper = new WindowsPathHelper(); + pathHelper.AddDotnetToAdminPath(); + } + else + { + // Not elevated, shell out to elevated process + writeOutput($"Launching elevated process to add {programFilesDotnetPath} to system PATH."); + bool succeeded = WindowsPathHelper.StartElevatedProcess("adddotnet"); + if (!succeeded) + { + writeError("Warning: Elevation was cancelled. System PATH was not modified."); + return false; + } + } + + return true; + } +} + +/// +/// Represents the changes needed to configure user install root. +/// +internal record UserInstallRootChanges( + string UserDotnetPath, + bool NeedsRemoveAdminPath, + bool NeedsAddToUserPath, + bool NeedsSetDotnetRoot, + string? NewUserPath, + List? FoundAdminDotnetPaths) +{ + /// + /// Checks if any changes are needed to configure user install root. + /// + public bool NeedsChange() => NeedsRemoveAdminPath || NeedsAddToUserPath || NeedsSetDotnetRoot; +} + +/// +/// Represents the changes needed to configure admin install root. +/// +internal record AdminInstallRootChanges( + string ProgramFilesDotnetPath, + bool NeedsModifyAdminPath, + bool NeedsModifyUserPath, + bool NeedsUnsetDotnetRoot, + string UserDotnetPath, + string? NewUserPath) +{ + /// + /// Checks if any changes are needed to configure admin install root. + /// + public bool NeedsChange() => NeedsModifyAdminPath || NeedsModifyUserPath || NeedsUnsetDotnetRoot; +} diff --git a/src/Installer/dotnetup/Parser.cs b/src/Installer/dotnetup/Parser.cs index 7032270df288..04ec40d2ced7 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.DefaultInstall; 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(DefaultInstallCommandParser.GetCommand()); return rootCommand; } diff --git a/src/Installer/dotnetup/WindowsPathHelper.cs b/src/Installer/dotnetup/WindowsPathHelper.cs new file mode 100644 index 000000000000..0c3414143bf1 --- /dev/null +++ b/src/Installer/dotnetup/WindowsPathHelper.cs @@ -0,0 +1,560 @@ +// 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. + /// + public static List SplitPath(string path) + { + return path.Split(';', StringSplitOptions.RemoveEmptyEntries).ToList(); + } + + /// + /// Finds the indices of entries in a PATH that match the specified paths. + /// This method is designed for unit testing without registry access. + /// + /// The list of PATH entries to search. + /// The list of paths to match. + /// A list of indices where paths were found. + public static List FindPathIndices(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 FindPathIndices(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 programFilesDotnetPaths = GetProgramFilesDotnetPaths(); + + // Remove those indices from the unexpanded path + return RemovePathEntries(unexpandedPath, expandedPath, programFilesDotnetPaths); + } + + /// + /// 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; + } + + return AddPathEntry(unexpandedPath, expandedPath, primaryDotnetPath); + } + + /// + /// 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 RemovePathEntries(string unexpandedPath, string expandedPath, List pathsToRemove) + { + var expandedEntries = SplitPath(expandedPath); + + // Find indices to remove using the expanded path + var indicesToRemove = FindPathIndices(expandedEntries, pathsToRemove); + + // 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 = FindPathIndices(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 operation) + { + var processPath = Environment.ProcessPath; + if (string.IsNullOrEmpty(processPath)) + { + throw new InvalidOperationException("Unable to determine current process path."); + } + + // We can't capture output directly from an elevated process, so we pass it a filename where + // it should write any output that should be displayed + var tempDirectory = Directory.CreateTempSubdirectory("dotnetup_elevated"); + string outputFilePath = Path.Combine(tempDirectory.FullName, "output.txt"); + + try + { + string arguments = $"elevatedadminpath {operation} \"{outputFilePath}\""; + + var startInfo = new ProcessStartInfo + { + FileName = processPath, + Arguments = arguments, + Verb = "runas", // This triggers UAC elevation + UseShellExecute = true, + CreateNoWindow = true, + WindowStyle = ProcessWindowStyle.Hidden + }; + + try + { + using var process = Process.Start(startInfo); + if (process == null) + { + throw new InvalidOperationException("Failed to start elevated process."); + } + + process.WaitForExit(); + + if (process.ExitCode == -2147450730) + { + // NOTE: Process exit code -2147450730 means that the right .NET runtime could not be found + // This should not happen when using NativeAOT dotnetup, but when testing using IL it can happen and + // can be caused if DOTNET_ROOT has been set to a path that doesn't have the right runtime to run dotnetup. + throw new InvalidOperationException("Elevated process failed: Unable to find matching .NET Runtime." + Environment.NewLine + + "This is probably because dotnetup is not being run as self-contained and DOTNET_ROOT is set to a path that doesn't have a matching runtime."); + } + else if (process.ExitCode != 0) + { + throw new InvalidOperationException( + $"Elevated process returned exit code {process.ExitCode}"); + } + + return true; + } + catch (System.ComponentModel.Win32Exception ex) + { + // User cancelled UAC prompt or elevation failed + // ERROR_CANCELLED = 1223 + if (ex.NativeErrorCode == 1223) + { + return false; + } + throw; + } + } + finally + { + // Show any output from elevated process + if (File.Exists(outputFilePath)) + { + string outputContent = File.ReadAllText(outputFilePath); + if (!string.IsNullOrEmpty(outputContent)) + { + Console.WriteLine(outputContent); + } + } + + // Clean up temporary directory + try + { + tempDirectory.Delete(recursive: true); + } + catch + { + // Ignore cleanup errors + } + } + } +} diff --git a/test/dotnetup.Tests/ParserTests.cs b/test/dotnetup.Tests/ParserTests.cs index 73e85a233cee..0554d58d1bb0 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", @"C:\Users\User\AppData\Local\Temp\dotnetup_elevated\output.txt" }; + + // Act + var parseResult = Parser.Parse(args); + + // Assert + parseResult.Should().NotBeNull(); + parseResult.Errors.Should().BeEmpty(); + } + + [Fact] + public void Parser_ShouldParseDefaultInstallCommand() + { + // Arrange + var args = new[] { "defaultinstall", "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..b47d8729580c --- /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.FindPathIndices(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.FindPathIndices(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%"); + } +}