Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
a86586f
Initial plan
Copilot Dec 16, 2025
542c866
Add WindowsPathHelper and new commands for PATH management
Copilot Dec 16, 2025
d8fed6a
Add tests for new PATH management commands
Copilot Dec 16, 2025
374d46e
Address code review feedback
Copilot Dec 16, 2025
6174dec
Run dotnet format to fix whitespace
Copilot Dec 16, 2025
3d163b5
Hide elevatedadminpath command from help output
Copilot Dec 16, 2025
5585351
Refactor PATH manipulation to preserve unexpanded environment variables
Copilot Dec 16, 2025
1d91179
Refactor WindowsPathHelper to be disposable with progressive logging
Copilot Dec 16, 2025
bbd3d5c
Address code review feedback on WindowsPathHelper
Copilot Dec 17, 2025
f4e9278
Address additional code review feedback
Copilot Dec 17, 2025
8aa862f
Replace IsElevated with Environment.IsPrivilegedProcess and add admin…
Copilot Dec 18, 2025
bd56b89
Add overload to display dotnet paths found in admin PATH
Copilot Dec 18, 2025
532dcd0
Simplify output and exception handling
dsplaisted Dec 18, 2025
b182662
Refactor PATH operations to preserve unexpanded environment variables
Copilot Dec 18, 2025
a0b0bc6
Simplify / remove duplication
dsplaisted Dec 29, 2025
aa9582d
Simplify / deduplicate AddPathEntry code
dsplaisted Dec 29, 2025
0fed369
Updates to SetInstallRoot
dsplaisted Dec 30, 2025
e027f31
Fix dotnetup elevation
dsplaisted Dec 30, 2025
eae3d56
Add output file from elevated process
dsplaisted Dec 30, 2025
945bf60
Simplify console output
dsplaisted Dec 31, 2025
02541a9
Simplify console output
dsplaisted Jan 1, 2026
521d885
Fix test
dsplaisted Jan 1, 2026
d143372
Rename setinstallroot command to defaultinstall
Copilot Jan 2, 2026
216bcc3
Refactor install root logic into InstallRootManager class
Copilot Jan 2, 2026
6bf0798
Cleanup InstallRootManager - remove unused methods and add convenienc…
Copilot Jan 2, 2026
dc487cc
Add download cache for .NET archives
Copilot Jan 2, 2026
9ecfd0c
Simplify download cache to use original filenames from URLs
Copilot Jan 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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();
Expand Down Expand Up @@ -192,6 +194,7 @@ void DownloadArchive(string downloadUrl, string destinationPath, IProgress<Downl

/// <summary>
/// Downloads the archive for the specified installation and verifies its hash.
/// Checks the download cache first to avoid re-downloading.
/// </summary>
/// <param name="install">The .NET installation details</param>
/// <param name="destinationPath">The local path to save the downloaded file</param>
Expand All @@ -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
}
}


Expand Down
146 changes: 146 additions & 0 deletions src/Installer/Microsoft.Dotnet.Installation/Internal/DownloadCache.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// JSON serialization context for the download cache index.
/// </summary>
[JsonSourceGenerationOptions(WriteIndented = true)]
[JsonSerializable(typeof(Dictionary<string, string>))]
internal partial class DownloadCacheJsonContext : JsonSerializerContext
{
}

/// <summary>
/// Manages a cache of downloaded .NET archives to avoid re-downloading files.
/// </summary>
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<string, string> _cacheIndex;

public DownloadCache()
{
_cacheIndex = LoadCacheIndex();
}

/// <summary>
/// Gets the path to a cached file for the given URL, if it exists.
/// </summary>
/// <param name="downloadUrl">The URL that was used to download the file</param>
/// <returns>The path to the cached file, or null if not found</returns>
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;
}

/// <summary>
/// Adds a file to the cache.
/// </summary>
/// <param name="downloadUrl">The URL the file was downloaded from</param>
/// <param name="sourceFilePath">The path to the file to cache</param>
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();
}

/// <summary>
/// Extracts the filename from a download URL.
/// </summary>
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;
}

/// <summary>
/// Loads the cache index from disk.
/// </summary>
private Dictionary<string, string> LoadCacheIndex()
{
if (!File.Exists(CacheIndexPath))
{
return new Dictionary<string, string>();
}

try
{
string json = File.ReadAllText(CacheIndexPath);
var index = JsonSerializer.Deserialize(json, DownloadCacheJsonContext.Default.DictionaryStringString);
return index ?? new Dictionary<string, string>();
}
catch
{
// If the index is corrupted, start fresh
return new Dictionary<string, string>();
}
}

/// <summary>
/// Saves the cache index to disk.
/// </summary>
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
}
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
Original file line number Diff line number Diff line change
@@ -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<string> InstallTypeArgument = CreateInstallTypeArgument();

private static Argument<string> CreateInstallTypeArgument()
{
var argument = new Argument<string>("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;
}
}
Loading