// Copyright Keysight Technologies 2012-2019
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at http://mozilla.org/MPL/2.0/.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.IO;
using System.Diagnostics;
using System.IO.Compression;
using System.Threading;
using System.ComponentModel;
namespace OpenTap.Package
{
internal enum ActionResult
{
///
/// An error occurred.
///
Error,
///
/// No error occurred.
///
Ok,
///
/// No action steps were defined for the given package.
///
NothingToDo
}
///
/// Executes a package action on a single package. Can optionally be used to execute a builtin action first.
///
internal class ActionExecuter
{
static TraceSource log = OpenTap.Log.CreateSource("Plugin");
private ConcurrentDictionary LogSources { get; } =
new ConcurrentDictionary();
///
/// The name of this rule.
///
public string ActionName { get; set; }
///
/// The inbuilt action that is executed first.
///
public Func Execute { get; set; }
public ActionResult DoExecute(PluginInstaller pluginInstaller, PackageDef package, bool force, string target)
{
try
{
if (Execute != null)
{
return Execute(pluginInstaller, this, package, force, target);
}
else
return ExecutePackageActionSteps(package, force, target);
}
catch (Exception ex)
{
log.Error(ex.Message);
return ActionResult.Error;
}
}
internal ActionResult ExecutePackageActionSteps(PackageDef package, bool force, string workingDirectory)
{
ActionResult res = ActionResult.NothingToDo;
// if the package is being installed as a system wide package, we'll want to look in the system-wide
// package folder for the executable. Additionally, the system-wide install directory will also be used as
// the working directory.
bool isSystemWide = package.IsSystemWide();
string systemWideDir = PackageDef.SystemWideInstallationDirectory;
IEnumerable possiblePaths(string file)
{
// If you want to run tap as a PackageActionExtension, you would specify "tap" as ExeFile for cross platform compatibility.
// In that case, File.Exists wont be successful on Windows. That is why .exe is added if needed.
if (isSystemWide)
{
yield return Path.Combine(systemWideDir, file);
if(!file.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
yield return Path.Combine(systemWideDir,file + ".exe");
}
yield return Path.Combine(workingDirectory, file);
if(!file.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
yield return Path.Combine(workingDirectory, file + ".exe");
}
foreach (var step in package.PackageActionExtensions)
{
if (step.ActionName.Equals(ActionName, StringComparison.OrdinalIgnoreCase) == false)
continue;
var stepName = $"'{step.ExeFile} {step.Arguments}'";
log.Info($"Starting {step.ActionName} step {stepName}");
var sw = Stopwatch.StartNew();
string exefile = step.ExeFile;
bool isTap = exefile.EndsWith("tap") || exefile.EndsWith("tap.exe");
if (isTap)
{
// Run verbose in order to inherit log source and log type
step.Arguments += " --verbose ";
}
// If path is relative, check if file is in fact in workingDirectory (isolated mode)
if (!Path.IsPathRooted(step.ExeFile))
{
exefile = possiblePaths(step.ExeFile).FirstOrDefault(File.Exists) ?? step.ExeFile;
}
// Upgrade to ok output
res = ActionResult.Ok;
var pi = new ProcessStartInfo(exefile, step.Arguments);
pi.CreateNoWindow = step.CreateNoWindow;
pi.WorkingDirectory = isSystemWide ? systemWideDir : workingDirectory;
pi.Environment.Remove(ExecutorSubProcess.EnvVarNames.ParentProcessExeDir);
// If OPENTAP_COLOR is set, the escape symbols for colors in the child process will break the parsing of the forwarded logs.
// Ensure color is never set in the child process. Colors will still be set in the parent process.
pi.Environment["OPENTAP_COLOR"] = "never";
try
{
Process p;
if (step.UseShellExecute)
{
pi.UseShellExecute = true;
p = Process.Start(pi);
}
else
{
pi.RedirectStandardOutput = true;
pi.RedirectStandardError = true;
pi.UseShellExecute = false;
p = Process.Start(pi);
p.ErrorDataReceived += (s, e) =>
{
if (step.Quiet)
return;
if (!string.IsNullOrEmpty(e.Data))
{
if (isTap)
RedirectTapLog(e.Data, true);
else
log.Error(e.Data);
}
};
p.OutputDataReceived += (s, e) =>
{
if (step.Quiet)
return;
if (!string.IsNullOrEmpty(e.Data))
{
if (isTap)
RedirectTapLog(e.Data, false);
else
log.Debug(e.Data);
}
};
p.BeginErrorReadLine();
p.BeginOutputReadLine();
}
p.WaitForExit();
if (step.ExpectedExitCodes != "*")
{
var expectedExitCodes = step.ExpectedExitCodes.Split(',').TrySelect(int.Parse,
(_, stringValue) => log.Error($"Failed to parse string '{stringValue}' as an integer."))
.ToHashSet();
if (expectedExitCodes.Count == 0)
expectedExitCodes.Add(0);
if (!expectedExitCodes.Contains(p.ExitCode))
throw new Exception($"Failed to run {step.ActionName} step {stepName}. Unexpected exitcode: {p.ExitCode}");
}
log.Info(sw, $"Successfully ran {step.ActionName} step {stepName}. {(p.ExitCode != 0 ? $"Exitcode: {p.ExitCode}" : "")}");
}
catch (Win32Exception) when (step.Optional)
{
log.Warning($"'{step.ExeFile}' not found, skipping action.");
}
catch (Exception e)
{
log.Error(sw, e.Message);
if (!force)
throw new Exception($"Failed to run {step.ActionName} package action.");
}
}
return res;
}
private void RedirectTapLog(string lines, bool IsStandardError)
{
foreach (var line in lines.Split('\n'))
{
var message = line;
string split = " : ";
var logParts = line.Split(new string[] { split }, StringSplitOptions.None);
if (logParts.Length < 4)
{
if (IsStandardError)
log.Error(message);
else
log.Info(message);
continue;
}
var sourceName = logParts[1].Trim();
var logType = logParts[2].Trim();
var idx = 0;
for (int i = 0; i < 3; i++)
{
idx = message.IndexOf(split, idx, StringComparison.Ordinal) + split.Length;
}
message = message.Substring(idx);
var source = LogSources.GetOrAdd(sourceName, Log.CreateSource);
switch (logType)
{
case "Information":
source.Info(message);
break;
case "Error":
source.Error(message);
break;
case "Warning":
source.Warning(message);
break;
default:
source.Debug(message);
break;
}
}
}
}
///
/// Install system for Tap Plugins, which are OpenTAP dll/waveforms in a renamed zip.
///
internal class PluginInstaller
{
static List builtinActions = new List
{
new ActionExecuter{ ActionName = Installer.Uninstall, Execute = DoUninstall }
};
static TraceSource log = OpenTap.Log.CreateSource("package");
///
/// Returns the names of the files in a plugin package.
///
///
///
internal static List FilesInPackage(string packagePath)
{
List files = new List();
string fileType = Path.GetExtension(packagePath);
if (fileType == ".xml")
{
var pkg = PackageDef.FromXml(packagePath);
return pkg.Files.Select(f => f.FileName).ToList();
}
try
{
using var fileStream = new FileStream(packagePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
using var zip = new ZipArchive(fileStream, ZipArchiveMode.Read);
foreach (var part in zip.Entries)
{
if (part.Name == "[Content_Types].xml" || part.Name == ".rels" || part.FullName.StartsWith("package/services/metadata/core-properties"))
continue; // skip strange extra files that are created by System.IO.Packaging.Package (TAP 7.x)
string path = Uri.UnescapeDataString(part.FullName);
files.Add(path);
}
}
catch (InvalidDataException)
{
log.Error($"Could not unpack '{packagePath}'.");
throw;
}
return files;
}
private static bool EulaAccept(PackageDef package)
{
if (string.IsNullOrWhiteSpace(package?.EULA?.Identifier)) return true;
string accept = $"{package.Name} - {package.EULA.Identifier}: Yes";
// These special characters should not occur, but let's not take any chances
accept = accept.Replace(":", "").Replace("\r", "").Replace("\n", "");
var EulaAcceptanceFile = Path.Combine(PackageCacheHelper.PackageCacheDirectory, "EulaAcceptance.txt");
var acceptedEulas = File.Exists(EulaAcceptanceFile) ? File.ReadLines(EulaAcceptanceFile) : [];
if (acceptedEulas.Contains(accept))
return true;
var EulaDialog = new EulaAcceptanceDialog(package);
UserInput.Request(EulaDialog);
if (EulaDialog.Answer == EulaAcceptanceDialog.Acceptance.Accept)
{
log.Info($"Accepted Eula {package.EULA.Identifier}");
File.AppendAllLines(EulaAcceptanceFile, [accept]);
return true;
}
return false;
}
///
/// Tries to install a plugin from 'path', throws an exception on error.
///
internal static PackageDef InstallPluginPackage(string target, string path, bool unpackOnly = false)
{
checkExtension(path);
checkFileExists(path);
var package = PackageDef.FromPackage(path);
if (!EulaAccept(package))
throw new Cli.ExitCodeException((int)PackageExitCodes.EulaNotAccepted, "EULA not accepted.");
var destination = package.IsSystemWide() ? PackageDef.SystemWideInstallationDirectory : target;
try
{
if (Path.GetExtension(path).ToLower().EndsWith("tappackages")) // This is a bundle
{
var tempDir = Path.GetTempPath();
var bundleFiles = UnpackPackage(path, tempDir);
foreach (var file in bundleFiles)
UnpackPackage(file, destination);
}
else
UnpackPackage(path, destination);
}
catch (Exception e)
{
log.Error($"Install failed to execute for '{package.Name}'.");
tryUninstall(path, package, target);
throw new Exception($"Failed to install package '{path}'.", e);
}
if (unpackOnly)
{
log.Info("Skipping install actions as unpack-only was specified.");
return package;
}
var pi = new PluginInstaller();
if (pi.ExecuteAction(package, Installer.Install, false, target) == ActionResult.Error)
{
log.Error($"Install package action failed to execute for '{package.Name}'.");
tryUninstall(path, package, target);
throw new Exception($"Failed to install package '{path}'.");
}
CustomPackageActionHelper.RunCustomActions(package, PackageActionStage.Install,
new CustomPackageActionArgs(null, false));
return package;
}
static void tryUninstall(string path, PackageDef package, string target)
{
log.Info("Uninstalling package '{0}'.", package.Name);
Uninstall(package, target);
log.Flush();
log.Info("Uninstalled package '{0}'.", path);
}
static void checkExtension(string path)
{
string pathGetExtension = Path.GetExtension(path);
if (String.Compare(pathGetExtension, ".tappackage", true) != 0 && String.Compare(pathGetExtension, ".tapplugin", true) != 0 && String.Compare(pathGetExtension, ".tappackages", true) != 0)
{
throw new Exception(String.Format("Unknown file type '{0}'.", pathGetExtension));
}
}
static void checkFileExists(string path)
{
if (!File.Exists(path))
{
throw new Exception(String.Format("TapPackage does not exist '{0}'.", path));
}
}
///
/// Unpacks a *.TapPackages file to the specified directory.
///
internal static List UnpackPackage(string packagePath, string destinationDir)
{
List installedParts = new List();
try
{
using (var packageStream = File.OpenRead(packagePath))
using (var zip = new ZipArchive(packageStream, ZipArchiveMode.Read))
{
foreach (var part in zip.Entries)
{
if (part.Name == "[Content_Types].xml" || part.Name == ".rels" || part.FullName.StartsWith("package/services/metadata/core-properties"))
continue; // skip strange extra files that are created by System.IO.Packaging.Package (TAP 7.x)
if (string.IsNullOrWhiteSpace(part.Name))
continue;
string path = Uri.UnescapeDataString(part.FullName).Replace('\\', '/');
path = Path.Combine(destinationDir, path).Replace('\\', '/');
var sw = Stopwatch.StartNew();
int Retries = 0, MaxRetries = 10;
while (true)
{
try
{
FileSystemHelper.EnsureDirectoryOf(path);
if (OperatingSystem.Current == OperatingSystem.Windows)
{
// on windows, hidden files cannot be overwritten.
// an exception will be thrown in File.Create further down.
if (Path.GetFileName(path).StartsWith(".") && File.Exists(path))
{
var attrs = File.GetAttributes(path);
var attrs2 = attrs & ~FileAttributes.Hidden;
if(attrs2 != attrs)
File.SetAttributes(path, attrs2);
}
}
var deflate_stream = part.Open();
using (var fileStream = File.Create(path))
{
var task = deflate_stream.CopyToAsync(fileStream, 4096, TapThread.Current.AbortToken);
ConsoleUtils.PrintProgressTillEnd(task, "Decompressing", () => fileStream.Position, () => part.Length);
}
log.Debug(sw, "Decompressed {0}", path);
installedParts.Add(path);
break;
}
catch (OperationCanceledException)
{
throw;
}
catch (IOException ex) when (ex.Message.Contains("There is not enough space on the disk"))
{
log.Error(ex.Message);
var req = new AbortOrRetryRequest("Not Enough Disk Space", $"File '{part.FullName}' requires {Utils.BytesToReadable(part.Length)} of free space. " +
$"Please free some space to continue.") {Response = AbortOrRetryResponse.Abort};
UserInput.Request(req, true);
if (req.Response == AbortOrRetryResponse.Abort)
throw new OperationCanceledException("Installation aborted due to missing disk space.");
}
catch (Exception ex)
{
if (Retries == MaxRetries)
throw;
Retries++;
log.Warning("Unable to unpack file {0}. Retry {1} of {2}.", path, Retries, MaxRetries);
log.Debug(ex);
Thread.Sleep(200);
}
}
}
}
}
catch (InvalidDataException)
{
log.Error($"Could not unpackage '{packagePath}'.");
throw;
}
SetHiddenAttributes(installedParts);
return installedParts;
}
private static void SetHiddenAttributes(List parts)
{
if (OperatingSystem.Current == OperatingSystem.Windows)
{
foreach (var path in parts)
{
// Set file hidden attribute
if (Path.GetFileName(path).StartsWith("."))
File.SetAttributes(path, FileAttributes.Hidden);
// Set directory hidden attribute
var hiddenIndex = path.IndexOf("/.");
while (hiddenIndex > 0)
{
var hiddenDirLength = path.Substring(++hiddenIndex).IndexOf('/');
if (hiddenDirLength > 0)
{
var tempPath = path.Substring(0, hiddenIndex + hiddenDirLength + 1);
File.SetAttributes(tempPath, FileAttributes.Hidden);
}
hiddenIndex = path.IndexOf("/.", ++hiddenIndex);
}
}
}
}
///
/// Unpackages a plugin file.
///
///
///
///
///
internal static bool UnpackageFile(string packagePath, string relativeFilePath, Stream destination)
{
try
{
using var fileStream = new FileStream(packagePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
using var zip = new ZipArchive(fileStream, ZipArchiveMode.Read);
foreach (var part in zip.Entries)
{
if (part.Name == "[Content_Types].xml" || part.Name == ".rels" || part.FullName.StartsWith("package/services/metadata/core-properties"))
continue; // skip strange extra files that are created by System.IO.Packaging.Package (TAP 7.x)
string path = Uri.UnescapeDataString(part.FullName);
if (path == relativeFilePath)
{
part.Open().CopyTo(destination);
return true;
}
}
}
catch (InvalidDataException)
{
log.Error($"Could not unpackage '{packagePath}'.");
throw;
}
return false;
}
///
/// Uninstalls a package.
///
internal static void Uninstall(PackageDef package, string target)
{
var pi = new PluginInstaller();
pi.ExecuteAction(package, Installer.PrepareUninstall, true, target);
pi.ExecuteAction(package, Installer.Uninstall, true, target);
}
internal ActionResult ExecuteAction(PackageDef package, string actionName, bool force, string target)
{
ActionExecuter action = builtinActions.FirstOrDefault(r => r.ActionName == actionName);
if (action == null)
action = new ActionExecuter { ActionName = actionName };
return action.DoExecute(this, package, force, target);
}
private static ActionResult DoUninstall(PluginInstaller pluginInstaller, ActionExecuter action, PackageDef package, bool force, string target)
{
var result = ActionResult.Ok;
var destination = package.IsSystemWide() ? PackageDef.SystemWideInstallationDirectory : target;
var installation = new Installation(destination);
var filesToRemain = installation.GetPackages()
.Where(p => p.Name != package.Name)
.SelectMany(p => p.Files)
.Select(f => f.RelativeDestinationPath)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
try
{
CustomPackageActionHelper.RunCustomActions(package, PackageActionStage.Uninstall, new CustomPackageActionArgs(null, force));
}
catch (Exception ex)
{
log.Error(ex);
result = ActionResult.Error;
}
try
{
if (action.ExecutePackageActionSteps(package, force, target) == ActionResult.Error)
throw new Exception();
}
catch
{
log.Error($"Uninstall package action failed to execute for package '{package.Name}'.");
result = ActionResult.Error;
}
var mover = UninstallContext.Create(installation);
foreach (var file in package.Files)
{
string fullPath = Path.Combine(destination, file.RelativeDestinationPath);
if (filesToRemain.Contains(file.RelativeDestinationPath))
{
log.Debug("Skipping deletion of file '{0}' since it is required by another plugin package.", file.RelativeDestinationPath);
continue;
}
log.Debug("Deleting file '{0}'.", file.RelativeDestinationPath);
if (!mover.Delete(file))
{
mover.UndoAllDeletions();
throw new Exception($"Unable to delete file '{file.RelativeDestinationPath}'.");
}
DeleteEmptyDirectory(new FileInfo(fullPath).Directory);
}
var packageFile = PackageDef.GetDefaultPackageMetadataPath(package, target);
if (!File.Exists(packageFile))
{
// TAP 8.x support:
packageFile = $"Package Definitions/{package.Name}.package.xml";
}
if (File.Exists(packageFile))
{
log.Debug("Deleting file '{0}'.", packageFile);
File.Delete(packageFile);
DeleteEmptyDirectory(new FileInfo(packageFile).Directory);
}
if (package.PackageSource is XmlPackageDefSource f2 && File.Exists(f2.PackageDefFilePath))
// in case the package def XML was not in the default package definition directory
// it is better to delete it anyway, because otherwise it will seem like it is still installed.
File.Delete(f2.PackageDefFilePath);
return result;
}
private static void DeleteEmptyDirectory(DirectoryInfo dir)
{
if (dir == null) return;
if (!dir.Exists) return;
if (dir.EnumerateFiles().Any() || dir.EnumerateDirectories().Any()) return;
try
{
dir.Delete(false);
// If it succeeded then we should check the parent directory.
DeleteEmptyDirectory(dir.Parent);
}
catch
{
// Do nothing, it's not a big deal anyway
}
}
}
}