using OpenTap.Cli; using System; using System.Collections.Generic; using System.ComponentModel; using System.Linq; using System.Threading; using OpenTap.Package.PackageInstallHelpers; #pragma warning disable 1591 // TODO: Add XML Comments in this file, then remove this namespace OpenTap.Package { [Display("uninstall", Group: "package", Description: "Uninstall one or more packages.")] public class PackageUninstallAction : IsolatedPackageAction { [CommandLineArgument("ignore-missing", Description = "Ignore packages in that are not currently installed.", ShortName = "i")] public bool IgnoreMissing { get; set; } [UnnamedCommandLineArgument("package(s)", Required = true, Description = "One or more packages to uninstall.")] public string[] Packages { get; set; } /// /// Never prompt for user input. /// [CommandLineArgument("non-interactive", Description = "Never prompt for user input.")] public bool NonInteractive { get; set; } = false; /// /// This will be set only if the install action was started by /// This is supposed to solve an issue where OpenTAP fails to detect that the process was elevated. /// If one level of elevation was already attempted, it is unlikely that further attempts will cause the check to succeed. /// In this instance, it is better to just try the installation with the current privileges, and fail with whatever /// error if those privileges are not sufficient. /// internal bool AlreadyElevated { get; set; } private int DoExecute(CancellationToken cancellationToken) { if (Force == false && Packages.Any(p => p == "OpenTAP") && Target == ExecutorClient.ExeDir) { log.Error( "Aborting request to uninstall the OpenTAP package that is currently executing as that would brick this installation. Use --force to uninstall anyway."); return (int) ExitCodes.ArgumentError; } // Skip auto-correction if ignore-missing is specified if (IgnoreMissing == false && NonInteractive == false) Packages = AutoCorrectPackageNames.Correct(Packages, Array.Empty()); var installation = new Installation(Target); var installedPackages = installation.GetPackages(); var packagePaths = new List(); var systemwidePackages = new List<(string path, PackageDef pkg)>(); bool anyUnrecognizedPlugins = false; foreach (string pack in Packages) { PackageDef package = installedPackages.FirstOrDefault(p => p.Name == pack); if (package != null && package.PackageSource is InstalledPackageDefSource source) { if (package.IsSystemWide()) systemwidePackages.Add((source.PackageDefFilePath, package)); else { packagePaths.Add(source.PackageDefFilePath); if (package.IsBundle()) packagePaths.AddRange(GetPaths(package, installedPackages)); } } else if (!IgnoreMissing) { log.Error("Package '{0}' is not installed", pack); anyUnrecognizedPlugins = true; } } if (anyUnrecognizedPlugins) return (int) PackageExitCodes.InvalidPackageName; if (!Force) { List additionalToRemove = new List(); switch (CheckPackageAndDependencies(installedPackages, packagePaths, additionalToRemove)) { case ContinueResponse.Cancel: log.Info("Uninstall cancelled by user."); return (int) ExitCodes.UserCancelled; case ContinueResponse.RemoveAll: packagePaths.AddRange(additionalToRemove.Select(x =>((InstalledPackageDefSource)x.PackageSource ).PackageDefFilePath)); foreach(var pkg in additionalToRemove) log.Info("Also removing {0} {1}.", pkg.Name, pkg.Version); break; case ContinueResponse.Continue: break; case ContinueResponse.Error: return (int) PackageExitCodes.PackageUninstallError; } } Installer installer = new Installer(Target, cancellationToken) {DoSleep = false}; installer.PackagePaths.AddRange(packagePaths); installer.ProgressUpdate += RaiseProgressUpdate; installer.Error += RaiseError; bool needElevation = !AlreadyElevated && systemwidePackages.Any() && SubProcessHost.IsAdmin() == false; // Warn the user if elevation was already attempted, and we are not currently running as admin if (AlreadyElevated && SubProcessHost.IsAdmin() == false) { log.Warning($"Process elevation failed. Installation will continue without elevation."); } // If we need to remove system-wide packages and we are not admin, we should remove them in an elevated sub-process if (needElevation) { RaiseProgressUpdate(10, "Removing system-wide packages."); var installStep = new PackageUninstallStep() { Packages = systemwidePackages.Select(p => p.pkg.Name).ToArray(), Force = Force, Target = PackageDef.SystemWideInstallationDirectory, }; var processRunner = new SubProcessHost { ForwardLogs = true, MutedSources = { "CLI", "Session", "Resolver", "AssemblyFinder", "PluginManager", "TestPlan", "UpdateCheck", "Installation" }, // The current install action is a locking package action. // Setting this flag lets the child process bypass the lock on the installation. Unlocked = true, }; var result = processRunner.Run(installStep, true, cancellationToken); if (result != Verdict.Pass) { var ex = new Exception( $"Failed installing system-wide packages. Try running the command as administrator."); RaiseError(ex); } var pct = (double)systemwidePackages.Count / (systemwidePackages.Count + packagePaths.Count) * 100; RaiseProgressUpdate((int)pct, "Removed system-wide packages."); } // Otherwise if we are admin and we need to install system-wide packages, we can install them in the current process else if (systemwidePackages.Any()) { installer.PackagePaths.AddRange(systemwidePackages.Select(p => p.path)); } var status = installer.RunCommand(Installer.PrepareUninstall, Force, false); if (status == (int) ExitCodes.GeneralException) return (int) PackageExitCodes.PackageUninstallError; status = installer.RunCommand(Installer.Uninstall, Force, true); if (status == (int) ExitCodes.GeneralException) return (int) PackageExitCodes.PackageUninstallError; return status; } protected override int LockedExecute(CancellationToken cancellationToken) { if (Packages == null) throw new Exception("No packages specified."); var currentInterface = UserInput.GetInterface(); if (NonInteractive) UserInput.SetInterface(new NonInteractiveUserInputInterface()); try { return DoExecute(cancellationToken); } finally { IncrementChangeId(Target); UserInput.SetInterface(currentInterface); } } private List GetPaths(PackageDef package, List installedPackages) { if (NonInteractive) { var bundledPackages = string.Join("\n", package.Dependencies.Select(d => d.Name)); log.Warning( $"Package '{package.Name}' is a bundle and has installed:\n{bundledPackages}\n\nThese packages must be uninstalled separately.\n"); log.Info($"Run the uninstall without the 'non-interactive' flag to interactively decide whether to keep or remove each package."); return new List(); } var result = new List(package.Dependencies.Count); // Get the names of all dependencies including the name of the bundle containing them var depNames = package.Dependencies.Select(d => d.Name).ToHashSet(); depNames.Add(package.Name); // Get all currently installed packages that are NOT part of this bundle var currentlyInstalled = installedPackages .Where(p => depNames.Contains(p.Name) == false).ToArray(); foreach (var dependency in package.Dependencies) { // Never offer to uninstall OpenTAP as this will lead to a broken install. if (dependency.Name == "OpenTAP") continue; // Detect if any other package depends on a package from this bundle // If another package depends on it, don't offer to uninstall itg var otherDepender = currentlyInstalled.FirstOrDefault(c => c.Dependencies.Any(d => d.Name == dependency.Name)); if (otherDepender != null) { log.Info( $"Package '{dependency.Name}' will not be uninstalled because '{otherDepender.Name}' still depends on it."); continue; } var dependencyPackage = installedPackages.FirstOrDefault(p => p.Name == dependency.Name); if (dependencyPackage?.PackageSource is XmlPackageDefSource source2) { var question = $"Package '{dependency.Name}' is a member of the bundle '{package.Name}'.\n" + $"Do you wish to uninstall '{dependency.Name}'?"; var req = new UninstallRequest(question) {Response = UninstallResponse.No}; UserInput.Request(req, true); if (req.Response == UninstallResponse.Yes) result.Add(source2.PackageDefFilePath); } } return result; } private ContinueResponse CheckPackageAndDependencies(List installed, List packagePaths, List removeAdditional) { var packages = packagePaths.Select(str => { var pkg = PackageDef.FromXml(str); pkg.PackageSource = new XmlPackageDefSource{PackageDefFilePath = str}; return pkg; }).ToList(); installed.RemoveIf(i => packages.Any(u => u.Name == i.Name && u.Version == i.Version)); var analyzer = DependencyAnalyzer.BuildAnalyzerContext(installed); var packagesWithIssues = new List(); foreach (var inst in installed) { if (analyzer.GetIssues(inst).Any(i => packages.Any(p => p.Name == i.PackageName))) packagesWithIssues.Add(inst); } if (packages.Any(p => p.Files.Any(f => f.FileName.ToLower().EndsWith("OpenTap.dll")))) { log.Error("OpenTAP cannot be uninstalled."); return ContinueResponse.Error; } if (packagesWithIssues.Any()) { log.Warning("Plugin Dependency Conflict."); var question = string.Format("One or more installed packages depend on {0}. Uninstalling might cause these packages to break:\n{1}", string.Join(" and ", packages.Select(p => p.Name)), string.Join("\n", packagesWithIssues.Select(p => p.Name + " " + p.Version))); var req = new ContinueRequest { message = question, Response = ContinueResponse.Cancel }; UserInput.Request(req, true); if (req.Response == ContinueResponse.RemoveAll) { removeAdditional.AddRange(packagesWithIssues); } return req.Response; } return ContinueResponse.Continue; } } enum ContinueResponse { [Display("Remove all the packages")] RemoveAll, // This will break the installation, so lets not present this as an option to the user. // --force can be used to force remove packages. [Browsable(false)] Continue, Cancel, [Browsable(false)] Error, } class ContinueRequest { [Browsable(true)] [Layout(LayoutMode.FullRow | LayoutMode.WrapText)] public string Message => message; internal string message; public string Name { get; private set; } = "Continue?"; [Submit] [Layout(LayoutMode.FullRow | LayoutMode.FloatBottom)] public ContinueResponse Response { get; set; } } enum UninstallResponse { Yes, No, } [Display("Uninstall bundled package?")] class UninstallRequest { public UninstallRequest(string message) { Message = message; } [Browsable(true)] [Layout(LayoutMode.FullRow | LayoutMode.WrapText)] public string Message { get; } [Layout(LayoutMode.FullRow | LayoutMode.FloatBottom)] [Submit] public UninstallResponse Response { get; set; } } }