|
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using System.Diagnostics;
using System.Runtime.Versioning;
using System.Text.Json;
using Microsoft.DotNet.Cli.Commands.Workload.Install.WorkloadInstallRecords;
using Microsoft.DotNet.Cli.Installer.Windows;
using Microsoft.DotNet.Cli.Utils;
using Microsoft.NET.Sdk.WorkloadManifestReader;
using Microsoft.Win32;
using Microsoft.Win32.Msi;
using static Microsoft.NET.Sdk.WorkloadManifestReader.WorkloadResolver;
namespace Microsoft.DotNet.Cli.Commands.Workload.Install;
/// <summary>
/// Creates a new <see cref="MsiInstallerBase"/> instance.
/// </summary>
/// <param name="dispatcher">The command dispatcher used for sending and receiving commands.</param>
/// <param name="logger"></param>
/// <param name="reporter"></param>
[SupportedOSPlatform("windows")]
internal abstract class MsiInstallerBase(InstallElevationContextBase elevationContext, ISetupLogger logger,
bool verifySignatures, IReporter reporter = null) : InstallerBase(elevationContext, logger, verifySignatures)
{
/// <summary>
/// Track messages that should never be reported more than once.
/// </summary>
private readonly HashSet<string> _reportedMessages = [];
/// <summary>
/// Backing field for the install location of .NET
/// </summary>
private string _dotNetHome;
private readonly JsonSerializerOptions _jsonSerializerOptions = new JsonSerializerOptions()
{
WriteIndented = true,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
/// <summary>
/// Full path to the root directory for storing workload data.
/// </summary>
public static readonly string WorkloadDataRoot = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "dotnet", "workloads");
/// <summary>
/// Default reinstall mode (equivalent to VOMUS).
/// </summary>
public const ReinstallMode DefaultReinstallMode = ReinstallMode.FILEOLDERVERSION | ReinstallMode.FILEVERIFY |
ReinstallMode.MACHINEDATA | ReinstallMode.USERDATA | ReinstallMode.SHORTCUT | ReinstallMode.PACKAGE;
/// <summary>
/// The prefix used when registering a dependent against a provider key.
/// </summary>
protected const string DependentPrefix = "Microsoft.NET.Sdk";
/// <summary>
/// Supported installer architectures used to map workload packs to architecture
/// specific payload packs to acquire MSIs.
/// </summary>
protected static readonly string[] SupportedArchitectures = ["x86", "amd64", "arm64"];
/// <summary>
/// Determines whether the parent process is still active.
/// </summary>
protected static bool IsParentProcessRunning => Process.GetProcessById(ParentProcess.Id) != null;
/// <summary>
/// Provides access to the underlying MSI cache.
/// </summary>
protected MsiPackageCache Cache
{
get;
private set;
} = new MsiPackageCache(elevationContext, logger, verifySignatures);
/// <summary>
/// The install location of the .NET based on the host and OS architecture as stored in the registry. If
/// no registry entry exists, the default location is returned.
/// </summary>
protected string DotNetHome
{
get
{
if (string.IsNullOrWhiteSpace(_dotNetHome))
{
_dotNetHome = GetDotNetHome();
}
return _dotNetHome;
}
}
protected readonly IReporter Reporter = reporter;
/// <summary>
/// A service controller representing the Windows Update agent (wuaserv).
/// </summary>
protected readonly WindowsUpdateAgent UpdateAgent = new WindowsUpdateAgent(logger);
/// <summary>
/// Provides access to workload installation records in the registry.
/// </summary>
protected readonly RegistryWorkloadInstallationRecordRepository RecordRepository = new RegistryWorkloadInstallationRecordRepository(elevationContext, logger, verifySignatures);
/// <summary>
/// Determines the per-machine install location for .NET. This is similar to the logic in the standalone installers.
/// </summary>
/// <returns>The path where .NET is installed based on the host architecture and operating system bitness.</returns>
internal static string GetDotNetHome()
{
// Configure the default location, e.g., if the registry key is absent. Technically that would be suggesting
// that the install is corrupt or we're being asked to run as an admin install in a non-admin deployment.
Environment.SpecialFolder programFiles = string.Equals(HostArchitecture, "x86") && Environment.Is64BitOperatingSystem
? Environment.SpecialFolder.ProgramFilesX86
: Environment.SpecialFolder.ProgramFiles;
string dotNetHome = Path.Combine(Environment.GetFolderPath(programFiles), "dotnet");
using RegistryKey hklm = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry32);
if (hklm != null)
{
using RegistryKey hostKey = hklm.OpenSubKey($@"SOFTWARE\dotnet\Setup\InstalledVersions\{HostArchitecture}");
if (hostKey != null)
{
string installLocation = (string)hostKey.GetValue("InstallLocation");
if (!string.IsNullOrWhiteSpace(installLocation))
{
return installLocation;
}
}
}
return dotNetHome;
}
/// <summary>
/// Configures the installer UI and logging operations before starting an operation.
/// </summary>
/// <param name="logFile">The path of the log file.</param>
protected void ConfigureInstall(string logFile)
{
// Turn off the MSI UI.
_ = WindowsInstaller.SetInternalUI(InstallUILevel.None);
// The log file must be created before calling MsiEnableLog and we should avoid having active handles
// against it.
FileStream logFileStream = File.Create(logFile);
logFileStream.Close();
uint error = WindowsInstaller.EnableLog(InstallLogMode.DEFAULT | InstallLogMode.VERBOSE, logFile, InstallLogAttributes.NONE);
// We can report issues with the log file creation, but shouldn't fail the workload operation.
LogError(error, $"Failed to configure log file: {logFile}");
}
/// <summary>
/// Repairs the specified MSI package.
/// </summary>
/// <param name="productCode">The product code of the MSI to repair.</param>
/// <param name="logFile">The full path of the log file.</param>
/// <returns>An error code indicating the result of the operation.</returns>
protected uint RepairMsi(string productCode, string logFile)
{
Elevate();
if (IsElevated)
{
ConfigureInstall(logFile);
return WindowsInstaller.ReinstallProduct(productCode, DefaultReinstallMode);
}
else if (IsClient)
{
InstallResponseMessage response = Dispatcher.SendMsiRequest(InstallRequestType.RepairMsi,
logFile, null, productCode);
ExitOnFailure(response, "Failed to repair MSI.");
return response.Error;
}
throw new InvalidOperationException($"Invalid configuration: elevated: {IsElevated}, client: {IsClient}");
}
/// <summary>
/// Instructs future workload operations to use workload sets or loose manifests, per newMode.
/// </summary>
/// <param name="sdkFeatureBand">The feature band to update</param>
/// <param name="newMode">Where to use loose manifests or workload sets</param>
/// <param name="logFile">Full path of the log file</param>
/// <returns>Error code indicating the result of the operation</returns>
/// <exception cref="InvalidOperationException"></exception>
protected void UpdateInstallMode(SdkFeatureBand sdkFeatureBand, bool? newMode)
{
string path = Path.Combine(WorkloadInstallType.GetInstallStateFolder(sdkFeatureBand, DotNetHome), "default.json");
var installStateContents = InstallStateContents.FromPath(path);
if (installStateContents.UseWorkloadSets == newMode)
{
return;
}
Elevate();
if (IsElevated)
{
// Create the parent folder for the state file and set up all required ACLs
installStateContents.UseWorkloadSets = newMode;
CreateSecureFileInDirectory(path, installStateContents.ToString());
}
else if (IsClient)
{
InstallResponseMessage response = Dispatcher.SendUpdateWorkloadModeRequest(sdkFeatureBand, newMode);
ExitOnFailure(response, "Failed to update install mode.");
}
else
{
throw new InvalidOperationException($"Invalid configuration: elevated: {IsElevated}, client: {IsClient}");
}
}
public void AdjustWorkloadSetInInstallState(SdkFeatureBand sdkFeatureBand, string workloadVersion)
{
string path = Path.Combine(WorkloadInstallType.GetInstallStateFolder(sdkFeatureBand, DotNetHome), "default.json");
var installStateContents = InstallStateContents.FromPath(path);
if (installStateContents.WorkloadVersion == null && workloadVersion == null ||
installStateContents.WorkloadVersion != null && installStateContents.WorkloadVersion.Equals(workloadVersion))
{
return;
}
Elevate();
if (IsElevated)
{
// Create the parent folder for the state file and set up all required ACLs
installStateContents.WorkloadVersion = workloadVersion;
CreateSecureFileInDirectory(path, installStateContents.ToString());
}
else if (IsClient)
{
InstallResponseMessage response = Dispatcher.SendUpdateWorkloadSetRequest(sdkFeatureBand, workloadVersion);
ExitOnFailure(response, "Failed to update install mode.");
}
else
{
throw new InvalidOperationException($"Invalid configuration: elevated: {IsElevated}, client: {IsClient}");
}
}
public void RecordWorkloadSetInGlobalJson(SdkFeatureBand sdkFeatureBand, string globalJsonPath, string workloadSetVersion)
{
Elevate();
if (IsElevated)
{
var workloadSetsFile = new GlobalJsonWorkloadSetsFile(sdkFeatureBand, DotNetHome);
SecurityUtils.CreateSecureDirectory(Path.GetDirectoryName(workloadSetsFile.Path));
workloadSetsFile.RecordWorkloadSetInGlobalJson(globalJsonPath, workloadSetVersion);
SecurityUtils.SecureFile(workloadSetsFile.Path);
}
else if (IsClient)
{
InstallResponseMessage response = Dispatcher.SendRecordWorkloadSetInGlobalJsonRequest(sdkFeatureBand, globalJsonPath, workloadSetVersion);
ExitOnFailure(response, "Failed to record workload set version in GC Roots file.");
}
else
{
throw new InvalidOperationException($"Invalid configuration: elevated: {IsElevated}, client: {IsClient}");
}
}
public Dictionary<string, string> GetGlobalJsonWorkloadSetVersions(SdkFeatureBand sdkFeatureBand)
{
Elevate();
if (IsElevated)
{
var workloadSetsFile = new GlobalJsonWorkloadSetsFile(sdkFeatureBand, DotNetHome);
SecurityUtils.CreateSecureDirectory(Path.GetDirectoryName(workloadSetsFile.Path));
var versions = workloadSetsFile.GetGlobalJsonWorkloadSetVersions();
// GetGlobalJsonWorkloadSetVersions will not create the file if it doesn't exist, so don't try to secure a non-existant file
if (File.Exists(workloadSetsFile.Path))
{
SecurityUtils.SecureFile(workloadSetsFile.Path);
}
return versions;
}
else if (IsClient)
{
InstallResponseMessage response = Dispatcher.SendGetGlobalJsonWorkloadSetVersionsRequest(sdkFeatureBand);
ExitOnFailure(response, "Failed to get global.json GC roots");
return response.GlobalJsonWorkloadSetVersions;
}
else
{
throw new InvalidOperationException($"Invalid configuration: elevated: {IsElevated}, client: {IsClient}");
}
}
/// <summary>
/// Installs the specified MSI.
/// </summary>
/// <param name="packagePath">The full path to the MSI package.</param>
/// <param name="logFile">The full path of the log file.</param>
/// <returns>An error code indicating the result of the operation.</returns>
protected uint InstallMsi(string packagePath, string logFile)
{
// Make sure the package we're going to run is coming from the cache.
if (!packagePath.StartsWith(Cache.PackageCacheRoot))
{
return Error.INSTALL_PACKAGE_INVALID;
}
Elevate();
if (IsElevated)
{
ConfigureInstall(logFile);
string installProperties = InstallProperties.Create(InstallProperties.SystemComponent,
InstallProperties.FastInstall, InstallProperties.SuppressReboot,
$@"DOTNETHOME=""{DotNetHome}""");
return WindowsInstaller.InstallProduct(packagePath, installProperties);
}
else if (IsClient)
{
InstallResponseMessage response = Dispatcher.SendMsiRequest(InstallRequestType.InstallMsi,
logFile, packagePath);
ExitOnFailure(response, "Failed to install MSI.");
return response.Error;
}
throw new InvalidOperationException($"Invalid configuration: elevated: {IsElevated}, client: {IsClient}");
}
/// <summary>
/// Uninstalls the MSI using its provided product code.
/// </summary>
/// <param name="productCode">The product code of the MSI to uninstall.</param>
/// <param name="logFile">The full path of the log file.</param>
/// <param name="ignoreDependencies">Controls whether dependency checks should be ignored when uninstalling.</param>
/// <returns>An error code indicating the result of the operation.</returns>
protected uint UninstallMsi(string productCode, string logFile, bool ignoreDependencies = false)
{
Elevate();
if (IsElevated)
{
ConfigureInstall(logFile);
string installProperties = InstallProperties.Create(InstallProperties.SystemComponent,
InstallProperties.FastInstall, InstallProperties.SuppressReboot,
InstallProperties.RemoveAll,
ignoreDependencies ? InstallProperties.IgnoreDependencies : null);
return WindowsInstaller.ConfigureProduct(productCode, WindowsInstaller.INSTALLLEVEL_DEFAULT, InstallState.ABSENT,
installProperties);
}
else if (IsClient)
{
InstallResponseMessage response = Dispatcher.SendMsiRequest(InstallRequestType.UninstallMsi,
logFile, null, productCode);
ExitOnFailure(response, "Failed to uninstall MSI.");
return response.Error;
}
throw new InvalidOperationException($"Invalid configuration: elevated: {IsElevated}, client: {IsClient}");
}
protected internal static string GetWorkloadHistoryDirectory(string sdkFeatureBand)
{
return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "dotnet", "workloads", RuntimeInformation.ProcessArchitecture.ToString(), sdkFeatureBand.ToString(), "history");
}
public void WriteWorkloadHistoryRecord(WorkloadHistoryRecord workloadHistoryRecord, string sdkFeatureBand)
{
var historyDirectory = GetWorkloadHistoryDirectory(sdkFeatureBand);
string logFile = Path.Combine(historyDirectory, $"{workloadHistoryRecord.TimeStarted:yyyy'-'MM'-'dd'T'HHmmss}_{workloadHistoryRecord.CommandName}.json");
Directory.CreateDirectory(historyDirectory);
File.WriteAllText(logFile, JsonSerializer.Serialize(workloadHistoryRecord, new JsonSerializerOptions() { WriteIndented = true }));
}
/// <summary>
/// Moves a file from one location to another if the destination file does not already exist.
/// </summary>
/// <param name="sourceFile">The source file to move.</param>
/// <param name="destinationFile">The destination where the source file will be moved.</param>
protected void MoveFile(string sourceFile, string destinationFile)
{
if (!File.Exists(destinationFile))
{
FileAccessRetrier.RetryOnMoveAccessFailure(() => File.Move(sourceFile, destinationFile));
Log?.LogMessage($"Moved '{sourceFile}' to '{destinationFile}'");
}
}
/// <summary>
/// Creates the log filename to use when executing an MSI. The name is based on the primary log, workload pack and <see cref="InstallAction"/>.
/// </summary>
/// <param name="packInfo">The workload pack to use when generating the log name.</param>
/// <param name="action">The install action that will be performed.</param>
/// <returns>The full path of the log file.</returns>
protected string GetMsiLogName(PackInfo packInfo, InstallAction action)
{
return Path.Combine(Path.GetDirectoryName(Log.LogPath),
Path.GetFileNameWithoutExtension(Log.LogPath) + $"_{packInfo.ResolvedPackageId}-{packInfo.Version}_{action}.log");
}
/// <summary>
/// Creates the log filename to use when executing an MSI. The name is based on the primary log, payload name and <see cref="InstallAction"/>.
/// </summary>
/// <param name="packInfo">The workload pack to use when generating the log name.</param>
/// <param name="action">The install action that will be performed.</param>
/// <returns>The full path of the log file.</returns>
protected string GetMsiLogName(MsiPayload msi, InstallAction action)
{
return Path.Combine(Path.GetDirectoryName(Log.LogPath),
Path.GetFileNameWithoutExtension(Log.LogPath) + $"_{msi.Manifest.Payload}_{action}.log");
}
/// <summary>
/// Creates the log filename to use when executing an MSI. The name is based on the primary log, ProductCode and <see cref="InstallAction"/>.
/// </summary>
/// <param name="packInfo">The workload pack to use when generating the log name.</param>
/// <param name="action">The install action that will be performed.</param>
/// <returns>The full path of the log file.</returns>
protected string GetMsiLogName(string productCode, InstallAction action)
{
return Path.Combine(Path.GetDirectoryName(Log.LogPath),
Path.GetFileNameWithoutExtension(Log.LogPath) + $"_{productCode}_{action}.log");
}
/// <summary>
/// Creates the log filename to use when performing an admin install on an MSI.
/// </summary>
/// <param name="msiPath">The full path to the MSI</param>
/// <returns>The full path of the log file</returns>
protected string GetMsiLogNameForAdminInstall(string msiPath)
{
return Path.Combine(Path.GetDirectoryName(Log.LogPath),
Path.GetFileNameWithoutExtension(Log.LogPath) + $"_{Path.GetFileNameWithoutExtension(msiPath)}_AdminInstall.log");
}
/// <summary>
/// Creates the log filename to use when executing an MSI. The name is based on the primary log, workload pack record and <see cref="InstallAction"/>.
/// </summary>
/// <param name="record">The workload record to use when generating the log name.</param>
/// <param name="action">The install action that will be performed.</param>
/// <returns>The full path of the log file.</returns>
protected string GetMsiLogName(WorkloadPackRecord record, InstallAction action)
{
return Path.Combine(Path.GetDirectoryName(Log.LogPath),
Path.GetFileNameWithoutExtension(Log.LogPath) + $"_{record.MsiId}-{record.MsiNuGetVersion}_{action}.log");
}
/// <summary>
/// Get a list of all MSI based SDK installations that match the current host architecture.
/// </summary>
/// <returns>A collection of all the installed SDKs. The collection may be empty if no installed versions are found.</returns>
internal static IEnumerable<string> GetInstalledSdkVersions()
{
// The SDK, regardless of the installer's platform, writes detection keys to the 32-bit hive.
using RegistryKey hklm32 = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry32);
if (hklm32 == null)
{
return [];
}
using RegistryKey installedSdkVersionsKey = hklm32.OpenSubKey(@$"SOFTWARE\dotnet\Setup\InstalledVersions\{HostArchitecture}\sdk");
// Call ToList() since the registry key handle will be disposed when exiting and deferred execution will fail.
return installedSdkVersionsKey?.GetValueNames().Where(name => !string.IsNullOrWhiteSpace(name)).ToList() ?? Enumerable.Empty<string>();
}
/// <summary>
/// Writes a messages to the underlying <see cref="IReporter"/> if the message has not previously been reported.
/// </summary>
/// <param name="message">The message to report.</param>
protected void ReportOnce(string message)
{
if (!_reportedMessages.Contains(message))
{
Reporter.WriteLine(message);
_reportedMessages.Add(message);
}
}
/// <summary>
/// Updates a dependency provider key by adding or removing a dependent.
/// </summary>
/// <param name="requestType">The action to perform on the provider key.</param>
/// <param name="providerKeyName">The provider key to update.</param>
/// <param name="dependent">The dependent to add or remove.</param>
protected void UpdateDependent(InstallRequestType requestType, string providerKeyName, string dependent)
{
DependencyProvider provider = new(providerKeyName, allUsers: true);
if (provider.Dependents.Contains(dependent) && requestType == InstallRequestType.AddDependent)
{
Log?.LogMessage($"Dependent already exists, {providerKeyName} won't be modified.");
return;
}
if (!provider.Dependents.Contains(dependent) && requestType == InstallRequestType.RemoveDependent)
{
Log?.LogMessage($"Dependent doesn't exist, {providerKeyName} won't be modified.");
return;
}
Elevate();
if (IsElevated)
{
if (requestType == InstallRequestType.RemoveDependent)
{
Log?.LogMessage($"Removing dependent '{dependent}' from provider '{providerKeyName}'");
// NB: Do not remove the provider key. The dependency provider custom action in the MSI will fail
// if it cannot find the key.
provider.RemoveDependent(dependent, removeProvider: false);
}
else if (requestType == InstallRequestType.AddDependent)
{
Log?.LogMessage($"Registering dependent '{dependent}' on provider '{providerKeyName}'");
provider.AddDependent(dependent);
}
}
else if (IsClient)
{
InstallResponseMessage response = Dispatcher.SendDependentRequest(requestType, providerKeyName, dependent);
ExitOnFailure(response, $"Failed to update dependent, providerKey: {providerKeyName}, dependent: {dependent}.");
}
}
/// <summary>
/// Deletes manifests from the install state file for the specified feature band.
/// </summary>
/// <param name="sdkFeatureBand">The feature band of the install state file.</param>
public void RemoveManifestsFromInstallState(SdkFeatureBand sdkFeatureBand)
{
string path = Path.Combine(WorkloadInstallType.GetInstallStateFolder(sdkFeatureBand, DotNetHome), "default.json");
var installStateContents = InstallStateContents.FromPath(path);
if (installStateContents.Manifests == null)
{
return;
}
if (!File.Exists(path))
{
Log?.LogMessage($"Install state file does not exist: {path}");
return;
}
Elevate();
if (IsElevated)
{
if (File.Exists(path))
{
installStateContents.Manifests = null;
File.WriteAllText(path, installStateContents.ToString());
}
}
else if (IsClient)
{
InstallResponseMessage response = Dispatcher.SendRemoveManifestsFromInstallStateFileRequest(sdkFeatureBand);
ExitOnFailure(response, $"Failed to remove install state file: {path}");
}
}
/// <summary>
/// Writes the contents of the install state JSON file.
/// </summary>
/// <param name="sdkFeatureBand">The path of the isntall state file to write.</param>
/// <param name="manifestContents">The contents of the JSON file, formatted as a single line.</param>
public void SaveInstallStateManifestVersions(SdkFeatureBand sdkFeatureBand, Dictionary<string, string> manifestContents)
{
string path = Path.Combine(WorkloadInstallType.GetInstallStateFolder(sdkFeatureBand, DotNetHome), "default.json");
var installStateContents = InstallStateContents.FromPath(path);
if (installStateContents.Manifests != null && // manifestContents should not be null here
installStateContents.Manifests.Count == manifestContents.Count &&
installStateContents.Manifests.All(m => manifestContents.TryGetValue(m.Key, out var val) && val.Equals(m.Value)))
{
return;
}
Elevate();
if (IsElevated)
{
// Create the parent folder for the state file and set up all required ACLs
installStateContents.Manifests = manifestContents;
CreateSecureFileInDirectory(path, installStateContents.ToString());
}
else if (IsClient)
{
InstallResponseMessage respone = Dispatcher.SendSaveInstallStateManifestVersions(sdkFeatureBand, manifestContents);
ExitOnFailure(respone, $"Failed to write install state file: {path}");
}
}
private static void CreateSecureFileInDirectory(string path, string contents)
{
SecurityUtils.CreateSecureDirectory(Path.GetDirectoryName(path));
File.WriteAllText(path, contents);
SecurityUtils.SecureFile(path);
}
}
|